simplify compaction: Agent owns config, compact() reloads everything

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 <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 16:08:41 -04:00
parent d419587c1b
commit af3929cc65
2 changed files with 53 additions and 179 deletions

View file

@ -69,6 +69,9 @@ pub struct Agent {
pub context: ContextState, pub context: ContextState,
/// Shared live context summary — TUI reads this directly for debug screen. /// Shared live context summary — TUI reads this directly for debug screen.
pub shared_context: SharedContextState, 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. /// Stable session ID for memory-search dedup across turns.
session_id: String, session_id: String,
/// Agent orchestration state (surface-observe, journal, reflect). /// Agent orchestration state (surface-observe, journal, reflect).
@ -90,6 +93,8 @@ impl Agent {
client: ApiClient, client: ApiClient,
system_prompt: String, system_prompt: String,
personality: Vec<(String, String)>, personality: Vec<(String, String)>,
app_config: crate::config::AppConfig,
prompt_file: String,
conversation_log: Option<ConversationLog>, conversation_log: Option<ConversationLog>,
shared_context: SharedContextState, shared_context: SharedContextState,
) -> Self { ) -> Self {
@ -116,6 +121,8 @@ impl Agent {
tokenizer, tokenizer,
context, context,
shared_context, shared_context,
app_config,
prompt_file,
session_id, session_id,
agent_cycles, agent_cycles,
}; };
@ -181,7 +188,7 @@ impl Agent {
pub fn budget(&self) -> ContextBudget { pub fn budget(&self) -> ContextBudget {
let count_str = |s: &str| self.tokenizer.encode_with_special_tokens(s).len(); 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 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) self.context.budget(&count_str, &count_msg, window)
} }
@ -332,7 +339,7 @@ impl Agent {
"[context overflow — compacting and retrying ({}/2)]", "[context overflow — compacting and retrying ({}/2)]",
overflow_retries, overflow_retries,
))); )));
self.emergency_compact(); self.compact();
continue; continue;
} }
if crate::thought::context::is_stream_error(&err) && empty_retries < 2 { 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 // Walk backwards from cutoff, accumulating entries within 5% of context
let count = |s: &str| self.tokenizer.encode_with_special_tokens(s).len(); 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; let journal_budget = context_window * 5 / 100;
dbg_log!("[journal] budget={} tokens ({}*5%)", journal_budget, context_window); dbg_log!("[journal] budget={} tokens ({}*5%)", journal_budget, context_window);
@ -899,21 +906,23 @@ impl Agent {
self.last_prompt_tokens self.last_prompt_tokens
} }
/// Build context window from conversation messages + journal. /// Rebuild the context window: reload identity, dedup, trim, reload journal.
/// Used by both compact() (in-memory messages) and restore_from_log() pub fn compact(&mut self) {
/// (conversation log). The context window is always: // Reload identity from config
/// identity + journal summaries + raw recent messages match crate::config::reload_for_model(&self.app_config, &self.prompt_file) {
pub fn compact(&mut self, new_system_prompt: String, new_personality: Vec<(String, String)>) { Ok((system_prompt, personality)) => {
self.context.system_prompt = new_system_prompt; self.context.system_prompt = system_prompt;
self.context.personality = new_personality; self.context.personality = personality;
self.do_compact(); }
} Err(e) => {
eprintln!("warning: failed to reload identity: {:#}", e);
/// Dedup memory entries, trim to fit, reload journal for new time range. }
fn trim_and_reload(&mut self, entries: &[ConversationEntry]) { }
// Dedup memory, trim to budget, reload journal
let entries = self.context.entries.clone();
self.context.entries = crate::thought::context::trim_entries( self.context.entries = crate::thought::context::trim_entries(
&self.context, &self.context,
entries, &entries,
&self.tokenizer, &self.tokenizer,
); );
self.load_startup_journal(); self.load_startup_journal();
@ -921,58 +930,24 @@ impl Agent {
self.publish_context_state(); 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 /// Restore from the conversation log. Builds the context window
/// the same way compact() does — journal summaries for old messages, /// the same way compact() does — journal summaries for old messages,
/// raw recent messages. This is the unified startup path. /// raw recent messages. This is the unified startup path.
/// Returns true if the log had content to restore. /// Returns true if the log had content to restore.
pub fn restore_from_log( pub fn restore_from_log(&mut self) -> bool {
&mut self,
system_prompt: String,
personality: Vec<(String, String)>,
) -> bool {
self.context.system_prompt = system_prompt;
self.context.personality = personality;
let entries = match &self.conversation_log { let entries = match &self.conversation_log {
Some(log) => match log.read_tail(512 * 1024) { Some(log) => match log.read_tail(2 * 1024 * 1024) {
Ok(entries) if !entries.is_empty() => { Ok(entries) if !entries.is_empty() => entries,
dbglog!("[restore] read {} entries from log tail", entries.len()); _ => return false,
entries
}
Ok(_) => {
dbglog!("[restore] log exists but is empty");
return false;
}
Err(e) => {
dbglog!("[restore] failed to read log: {}", e);
return false;
}
}, },
None => { None => return false,
dbglog!("[restore] no conversation log configured");
return false;
}
}; };
// Filter out system messages, dedup memory, trim to fit // Load extra — compact() will dedup, trim, reload identity + journal
let entries: Vec<ConversationEntry> = entries self.context.entries = entries.into_iter()
.into_iter()
.filter(|e| e.message().role != Role::System) .filter(|e| e.message().role != Role::System)
.collect(); .collect();
self.trim_and_reload(&entries); self.compact();
dbglog!("[restore] {} entries, journal: {} entries",
self.context.entries.len(), self.context.journal.len());
true true
} }

View file

@ -38,18 +38,11 @@ use poc_memory::agent::tui::HotkeyAction;
use poc_memory::config::{self, AppConfig, SessionConfig}; use poc_memory::config::{self, AppConfig, SessionConfig};
use poc_memory::agent::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage}; use poc_memory::agent::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage};
/// Hard compaction threshold — context is rebuilt immediately. /// Compaction threshold — context is rebuilt when prompt tokens exceed this.
/// Uses config percentage of model context window.
fn compaction_threshold(app: &AppConfig) -> u32 { fn compaction_threshold(app: &AppConfig) -> u32 {
(poc_memory::thought::context::context_window() as u32) * app.compaction.hard_threshold_pct / 100 (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] #[tokio::main]
async fn main() { async fn main() {
let cli = cli::CliArgs::parse(); let cli = cli::CliArgs::parse();
@ -140,7 +133,6 @@ struct Session {
last_user_input: Instant, last_user_input: Instant,
consecutive_errors: u32, consecutive_errors: u32,
last_turn_had_tools: bool, last_turn_had_tools: bool,
pre_compaction_nudged: bool,
} }
impl Session { impl Session {
@ -172,7 +164,6 @@ impl Session {
last_user_input: Instant::now(), last_user_input: Instant::now(),
consecutive_errors: 0, consecutive_errors: 0,
last_turn_had_tools: false, last_turn_had_tools: false,
pre_compaction_nudged: false,
} }
} }
@ -280,41 +271,19 @@ impl Session {
async fn check_compaction(&mut self) { async fn check_compaction(&mut self) {
let mut agent_guard = self.agent.lock().await; let mut agent_guard = self.agent.lock().await;
let tokens = agent_guard.last_prompt_tokens(); let tokens = agent_guard.last_prompt_tokens();
let hard = compaction_threshold(&self.config.app); let threshold = compaction_threshold(&self.config.app);
let soft = pre_compaction_threshold(&self.config.app);
if tokens > hard { if tokens > threshold {
let _ = self.ui_tx.send(UiMessage::Info(format!( let _ = self.ui_tx.send(UiMessage::Info(format!(
"[compaction: {}K > {}K threshold]", "[compaction: {}K > {}K threshold]",
tokens / 1000, tokens / 1000,
hard / 1000, threshold / 1000,
))); )));
match config::reload_for_model(&self.config.app, &self.config.prompt_file) { agent_guard.compact();
Ok((system_prompt, personality)) => { let _ = self.ui_tx.send(UiMessage::Info(
agent_guard.compact(system_prompt, personality); "[compacted — journal + recent messages]".into(),
let _ = self.ui_tx.send(UiMessage::Info( ));
"[compacted — journal + recent messages]".into(), self.send_context_info();
));
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(),
);
} }
} }
@ -376,10 +345,8 @@ impl Session {
("/quit", "Exit poc-agent"), ("/quit", "Exit poc-agent"),
("/new", "Start fresh session (saves current)"), ("/new", "Start fresh session (saves current)"),
("/save", "Save session to disk"), ("/save", "Save session to disk"),
("/compact", "Rebuild context window now"),
("/retry", "Re-run last turn"), ("/retry", "Re-run last turn"),
("/model", "Show/switch model (/model <name>)"), ("/model", "Show/switch model (/model <name>)"),
("/context", "Show context window stats"),
("/dmn", "Show DMN state"), ("/dmn", "Show DMN state"),
("/sleep", "Put DMN to sleep"), ("/sleep", "Put DMN to sleep"),
("/wake", "Wake DMN to foraging"), ("/wake", "Wake DMN to foraging"),
@ -418,6 +385,8 @@ impl Session {
), ),
self.config.system_prompt.clone(), self.config.system_prompt.clone(),
self.config.context_parts.clone(), self.config.context_parts.clone(),
self.config.app.clone(),
self.config.prompt_file.clone(),
new_log, new_log,
shared_ctx, shared_ctx,
); );
@ -446,65 +415,6 @@ impl Session {
} }
Command::Handled 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" => { "/dmn" => {
let _ = self let _ = self
.ui_tx .ui_tx
@ -772,22 +682,12 @@ impl Session {
if prompt_changed { if prompt_changed {
self.config.prompt_file = resolved.prompt_file.clone(); self.config.prompt_file = resolved.prompt_file.clone();
match config::reload_for_model(&self.config.app, &resolved.prompt_file) { agent_guard.prompt_file = resolved.prompt_file.clone();
Ok((system_prompt, personality)) => { agent_guard.compact();
self.config.system_prompt = system_prompt.clone(); let _ = self.ui_tx.send(UiMessage::Info(format!(
self.config.context_parts = personality.clone(); "Switched to {} ({}) — prompt: {}, recompacted",
agent_guard.compact(system_prompt, personality); name, resolved.model_id, resolved.prompt_file,
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,
)));
}
}
} else { } else {
let _ = self.ui_tx.send(UiMessage::Info(format!( let _ = self.ui_tx.send(UiMessage::Info(format!(
"Switched to {} ({})", "Switched to {} ({})",
@ -900,6 +800,8 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
client, client,
config.system_prompt.clone(), config.system_prompt.clone(),
config.context_parts.clone(), config.context_parts.clone(),
config.app.clone(),
config.prompt_file.clone(),
Some(conversation_log), Some(conversation_log),
shared_context, shared_context,
))); )));
@ -911,10 +813,7 @@ async fn run(cli: cli::CliArgs) -> Result<()> {
// Restore conversation from the append-only log // Restore conversation from the append-only log
{ {
let mut agent_guard = agent.lock().await; let mut agent_guard = agent.lock().await;
if agent_guard.restore_from_log( if agent_guard.restore_from_log() {
config.system_prompt.clone(),
config.context_parts.clone(),
) {
replay_session_to_ui(agent_guard.entries(), &ui_tx); replay_session_to_ui(agent_guard.entries(), &ui_tx);
let _ = ui_tx.send(UiMessage::Info( let _ = ui_tx.send(UiMessage::Info(
"--- restored from conversation log ---".into(), "--- restored from conversation log ---".into(),