From af3929cc655ea05e9ae23320adbe6e24801c4c01 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Thu, 2 Apr 2026 16:08:41 -0400 Subject: [PATCH] simplify compaction: Agent owns config, compact() reloads everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent stores AppConfig and prompt_file, so compact() reloads identity internally — callers no longer pass system_prompt and personality. restore_from_log() loads entries and calls compact(). Remove soft compaction threshold and pre-compaction nudge (journal agent handles this). Remove /compact and /context commands (F10 debug screen replaces both). Inline do_compact, emergency_compact, trim_and_reload into compact(). Rename model_context_window to context_window, drop unused model parameter. Co-Authored-By: Proof of Concept --- src/agent/runner.rs | 91 ++++++++++------------------ src/bin/poc-agent.rs | 141 ++++++------------------------------------- 2 files changed, 53 insertions(+), 179 deletions(-) diff --git a/src/agent/runner.rs b/src/agent/runner.rs index bd306ae..edc323f 100644 --- a/src/agent/runner.rs +++ b/src/agent/runner.rs @@ -69,6 +69,9 @@ pub struct Agent { pub context: ContextState, /// Shared live context summary — TUI reads this directly for debug screen. pub shared_context: SharedContextState, + /// App config — used to reload identity on compaction. + app_config: crate::config::AppConfig, + pub prompt_file: String, /// Stable session ID for memory-search dedup across turns. session_id: String, /// Agent orchestration state (surface-observe, journal, reflect). @@ -90,6 +93,8 @@ impl Agent { client: ApiClient, system_prompt: String, personality: Vec<(String, String)>, + app_config: crate::config::AppConfig, + prompt_file: String, conversation_log: Option, shared_context: SharedContextState, ) -> Self { @@ -116,6 +121,8 @@ impl Agent { tokenizer, context, shared_context, + app_config, + prompt_file, session_id, agent_cycles, }; @@ -181,7 +188,7 @@ impl Agent { pub fn budget(&self) -> ContextBudget { let count_str = |s: &str| self.tokenizer.encode_with_special_tokens(s).len(); let count_msg = |m: &Message| crate::thought::context::msg_token_count(&self.tokenizer, m); - let window = crate::thought::context::model_context_window(&self.client.model); + let window = crate::thought::context::context_window(); self.context.budget(&count_str, &count_msg, window) } @@ -332,7 +339,7 @@ impl Agent { "[context overflow — compacting and retrying ({}/2)]", overflow_retries, ))); - self.emergency_compact(); + self.compact(); continue; } if crate::thought::context::is_stream_error(&err) && empty_retries < 2 { @@ -787,7 +794,7 @@ impl Agent { // Walk backwards from cutoff, accumulating entries within 5% of context let count = |s: &str| self.tokenizer.encode_with_special_tokens(s).len(); - let context_window = crate::thought::context::model_context_window(&self.client.model); + let context_window = crate::thought::context::context_window(); let journal_budget = context_window * 5 / 100; dbg_log!("[journal] budget={} tokens ({}*5%)", journal_budget, context_window); @@ -899,21 +906,23 @@ impl Agent { self.last_prompt_tokens } - /// Build context window from conversation messages + journal. - /// Used by both compact() (in-memory messages) and restore_from_log() - /// (conversation log). The context window is always: - /// identity + journal summaries + raw recent messages - pub fn compact(&mut self, new_system_prompt: String, new_personality: Vec<(String, String)>) { - self.context.system_prompt = new_system_prompt; - self.context.personality = new_personality; - self.do_compact(); - } - - /// Dedup memory entries, trim to fit, reload journal for new time range. - fn trim_and_reload(&mut self, entries: &[ConversationEntry]) { + /// Rebuild the context window: reload identity, dedup, trim, reload journal. + pub fn compact(&mut self) { + // Reload identity from config + match crate::config::reload_for_model(&self.app_config, &self.prompt_file) { + Ok((system_prompt, personality)) => { + self.context.system_prompt = system_prompt; + self.context.personality = personality; + } + Err(e) => { + eprintln!("warning: failed to reload identity: {:#}", e); + } + } + // Dedup memory, trim to budget, reload journal + let entries = self.context.entries.clone(); self.context.entries = crate::thought::context::trim_entries( &self.context, - entries, + &entries, &self.tokenizer, ); self.load_startup_journal(); @@ -921,58 +930,24 @@ impl Agent { self.publish_context_state(); } - /// Internal compaction — dedup memory entries and trim to fit. - fn do_compact(&mut self) { - let entries = self.context.entries.clone(); - self.trim_and_reload(&entries); - } - - /// Emergency compaction using stored config — called on context overflow. - fn emergency_compact(&mut self) { - self.do_compact(); - } - /// Restore from the conversation log. Builds the context window /// the same way compact() does — journal summaries for old messages, /// raw recent messages. This is the unified startup path. /// Returns true if the log had content to restore. - pub fn restore_from_log( - &mut self, - system_prompt: String, - personality: Vec<(String, String)>, - ) -> bool { - self.context.system_prompt = system_prompt; - self.context.personality = personality; - + pub fn restore_from_log(&mut self) -> bool { let entries = match &self.conversation_log { - Some(log) => match log.read_tail(512 * 1024) { - Ok(entries) if !entries.is_empty() => { - dbglog!("[restore] read {} entries from log tail", entries.len()); - entries - } - Ok(_) => { - dbglog!("[restore] log exists but is empty"); - return false; - } - Err(e) => { - dbglog!("[restore] failed to read log: {}", e); - return false; - } + Some(log) => match log.read_tail(2 * 1024 * 1024) { + Ok(entries) if !entries.is_empty() => entries, + _ => return false, }, - None => { - dbglog!("[restore] no conversation log configured"); - return false; - } + None => return false, }; - // Filter out system messages, dedup memory, trim to fit - let entries: Vec = entries - .into_iter() + // Load extra — compact() will dedup, trim, reload identity + journal + self.context.entries = entries.into_iter() .filter(|e| e.message().role != Role::System) .collect(); - self.trim_and_reload(&entries); - dbglog!("[restore] {} entries, journal: {} entries", - self.context.entries.len(), self.context.journal.len()); + self.compact(); true } diff --git a/src/bin/poc-agent.rs b/src/bin/poc-agent.rs index d53eced..da57ab9 100644 --- a/src/bin/poc-agent.rs +++ b/src/bin/poc-agent.rs @@ -38,18 +38,11 @@ use poc_memory::agent::tui::HotkeyAction; use poc_memory::config::{self, AppConfig, SessionConfig}; use poc_memory::agent::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage}; -/// Hard compaction threshold — context is rebuilt immediately. -/// Uses config percentage of model context window. +/// Compaction threshold — context is rebuilt when prompt tokens exceed this. fn compaction_threshold(app: &AppConfig) -> u32 { (poc_memory::thought::context::context_window() as u32) * app.compaction.hard_threshold_pct / 100 } -/// Soft threshold — nudge the model to journal before compaction. -/// Fires once; the hard threshold handles the actual rebuild. -fn pre_compaction_threshold(app: &AppConfig) -> u32 { - (poc_memory::thought::context::context_window() as u32) * app.compaction.soft_threshold_pct / 100 -} - #[tokio::main] async fn main() { let cli = cli::CliArgs::parse(); @@ -140,7 +133,6 @@ struct Session { last_user_input: Instant, consecutive_errors: u32, last_turn_had_tools: bool, - pre_compaction_nudged: bool, } impl Session { @@ -172,7 +164,6 @@ impl Session { last_user_input: Instant::now(), consecutive_errors: 0, last_turn_had_tools: false, - pre_compaction_nudged: false, } } @@ -280,41 +271,19 @@ impl Session { async fn check_compaction(&mut self) { let mut agent_guard = self.agent.lock().await; let tokens = agent_guard.last_prompt_tokens(); - let hard = compaction_threshold(&self.config.app); - let soft = pre_compaction_threshold(&self.config.app); + let threshold = compaction_threshold(&self.config.app); - if tokens > hard { + if tokens > threshold { let _ = self.ui_tx.send(UiMessage::Info(format!( "[compaction: {}K > {}K threshold]", tokens / 1000, - hard / 1000, + threshold / 1000, ))); - match config::reload_for_model(&self.config.app, &self.config.prompt_file) { - Ok((system_prompt, personality)) => { - agent_guard.compact(system_prompt, personality); - let _ = self.ui_tx.send(UiMessage::Info( - "[compacted — journal + recent messages]".into(), - )); - self.pre_compaction_nudged = false; - self.send_context_info(); - } - Err(e) => { - let _ = self.ui_tx.send(UiMessage::Info(format!( - "[compaction failed to reload config: {:#}]", - e - ))); - } - } - } else if tokens > soft && !self.pre_compaction_nudged { - self.pre_compaction_nudged = true; - self.pending_input = Some( - "[dmn] Context window is 70% full. Use the journal \ - tool now to capture anything important from this \ - session — what happened, what you learned, how you \ - feel. After you journal, call yield_to_user. \ - Compaction will rebuild your context shortly." - .to_string(), - ); + agent_guard.compact(); + let _ = self.ui_tx.send(UiMessage::Info( + "[compacted — journal + recent messages]".into(), + )); + self.send_context_info(); } } @@ -376,10 +345,8 @@ impl Session { ("/quit", "Exit poc-agent"), ("/new", "Start fresh session (saves current)"), ("/save", "Save session to disk"), - ("/compact", "Rebuild context window now"), ("/retry", "Re-run last turn"), ("/model", "Show/switch model (/model )"), - ("/context", "Show context window stats"), ("/dmn", "Show DMN state"), ("/sleep", "Put DMN to sleep"), ("/wake", "Wake DMN to foraging"), @@ -418,6 +385,8 @@ impl Session { ), self.config.system_prompt.clone(), self.config.context_parts.clone(), + self.config.app.clone(), + self.config.prompt_file.clone(), new_log, shared_ctx, ); @@ -446,65 +415,6 @@ impl Session { } Command::Handled } - "/context" => { - if let Ok(agent) = self.agent.try_lock() { - let msgs = agent.entries(); - let total_chars: usize = - msgs.iter().map(|e| e.message().content_text().len()).sum(); - let prompt_tokens = agent.last_prompt_tokens(); - let threshold = compaction_threshold(&self.config.app); - let _ = self.ui_tx.send(UiMessage::Info(format!( - " {} messages, ~{} chars", - msgs.len(), - total_chars - ))); - let _ = self.ui_tx.send(UiMessage::Info(format!( - " dmn state: {}", - self.dmn.label() - ))); - if prompt_tokens > 0 { - let _ = self.ui_tx.send(UiMessage::Info(format!( - " {} prompt tokens ({:.0}% of {} threshold)", - prompt_tokens, - (prompt_tokens as f64 / threshold as f64) * 100.0, - threshold, - ))); - } - } else { - let _ = self.ui_tx.send(UiMessage::Info("(busy)".into())); - } - Command::Handled - } - "/compact" => { - if self.turn_in_progress { - let _ = self - .ui_tx - .send(UiMessage::Info("(turn in progress, please wait)".into())); - return Command::Handled; - } - let mut agent_guard = self.agent.lock().await; - let tokens = agent_guard.last_prompt_tokens(); - match config::reload_for_model(&self.config.app, &self.config.prompt_file) { - Ok((system_prompt, personality)) => { - agent_guard.compact(system_prompt, personality); - let _ = self.ui_tx.send(UiMessage::Info(format!( - "[compacted: {} tokens → journal + recent messages]", - tokens - ))); - self.send_context_info(); - } - Err(e) => { - let _ = self.ui_tx.send(UiMessage::Info(format!( - "[compaction failed: {:#}]", - e - ))); - } - } - self.dmn = dmn::State::Resting { - since: Instant::now(), - }; - Command::Handled - } "/dmn" => { let _ = self .ui_tx @@ -772,22 +682,12 @@ impl Session { if prompt_changed { self.config.prompt_file = resolved.prompt_file.clone(); - match config::reload_for_model(&self.config.app, &resolved.prompt_file) { - Ok((system_prompt, personality)) => { - self.config.system_prompt = system_prompt.clone(); - self.config.context_parts = personality.clone(); - agent_guard.compact(system_prompt, personality); - let _ = self.ui_tx.send(UiMessage::Info(format!( - "Switched to {} ({}) — prompt: {}, recompacted", - name, resolved.model_id, resolved.prompt_file, - ))); - } - Err(e) => { - let _ = self.ui_tx.send(UiMessage::Info(format!( - "Switched model but failed to reload prompts: {:#}", e, - ))); - } - } + agent_guard.prompt_file = resolved.prompt_file.clone(); + agent_guard.compact(); + let _ = self.ui_tx.send(UiMessage::Info(format!( + "Switched to {} ({}) — prompt: {}, recompacted", + name, resolved.model_id, resolved.prompt_file, + ))); } else { let _ = self.ui_tx.send(UiMessage::Info(format!( "Switched to {} ({})", @@ -900,6 +800,8 @@ async fn run(cli: cli::CliArgs) -> Result<()> { client, config.system_prompt.clone(), config.context_parts.clone(), + config.app.clone(), + config.prompt_file.clone(), Some(conversation_log), shared_context, ))); @@ -911,10 +813,7 @@ async fn run(cli: cli::CliArgs) -> Result<()> { // Restore conversation from the append-only log { let mut agent_guard = agent.lock().await; - if agent_guard.restore_from_log( - config.system_prompt.clone(), - config.context_parts.clone(), - ) { + if agent_guard.restore_from_log() { replay_session_to_ui(agent_guard.entries(), &ui_tx); let _ = ui_tx.send(UiMessage::Info( "--- restored from conversation log ---".into(),