From fa64df05fff11fb892fe0cc2d808cde0a7e6422a Mon Sep 17 00:00:00 2001 From: Nitanshu Lokhande <56120084+nlok5923@users.noreply.github.com> Date: Fri, 20 Feb 2026 06:43:25 +0530 Subject: [PATCH] feat: wire memory hygiene into the heartbeat loop (#195) * feat: wire memory hygiene into heartbeat loop (#166) * refactor: address PR review comments for hygiene wiring * style: fix fmt import ordering and clippy too_many_arguments warning * fix: update heartbeat integration test to pass HygieneConfig argument HeartbeatRunner::new() now requires a HygieneConfig as its second argument after the hygiene wiring refactor. Pass the default config in the integration test. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Illia Polosukhin --- .env.example | 6 +++ src/agent/agent_loop.rs | 11 ++++++ src/agent/commands.rs | 1 + src/agent/heartbeat.rs | 23 ++++++++++- src/config/hygiene.rs | 70 ++++++++++++++++++++++++++++++++++ src/config/mod.rs | 4 ++ src/main.rs | 1 + tests/heartbeat_integration.rs | 3 +- 8 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/config/hygiene.rs diff --git a/.env.example b/.env.example index a1f57476..dabed097 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,12 @@ HEARTBEAT_INTERVAL_SECS=1800 HEARTBEAT_NOTIFY_CHANNEL=cli HEARTBEAT_NOTIFY_USER=default +# Memory hygiene settings (automatic cleanup of stale workspace documents) +# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted +# MEMORY_HYGIENE_ENABLED=true +# MEMORY_HYGIENE_RETENTION_DAYS=30 # delete daily/ docs older than this many days +# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes + # Safety settings SAFETY_MAX_OUTPUT_LENGTH=100000 SAFETY_INJECTION_CHECK_ENABLED=true diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 45152cd3..6d4a9553 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -85,6 +85,7 @@ pub struct Agent { pub(super) session_manager: Arc, pub(super) context_monitor: ContextMonitor, pub(super) heartbeat_config: Option, + pub(super) hygiene_config: Option, pub(super) routine_config: Option, } @@ -93,11 +94,13 @@ impl Agent { /// /// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing /// with external components (job tools, web gateway). Creates new ones if not provided. + #[allow(clippy::too_many_arguments)] pub fn new( config: AgentConfig, deps: AgentDeps, channels: ChannelManager, heartbeat_config: Option, + hygiene_config: Option, routine_config: Option, context_manager: Option>, session_manager: Option>, @@ -127,6 +130,7 @@ impl Agent { session_manager, context_monitor: ContextMonitor::new(), heartbeat_config, + hygiene_config, routine_config, } } @@ -358,8 +362,15 @@ impl Agent { "Heartbeat enabled with {}s interval", hb_config.interval_secs ); + let hygiene = self + .hygiene_config + .as_ref() + .map(|h| h.to_workspace_config()) + .unwrap_or_default(); + Some(spawn_heartbeat( config, + hygiene, workspace.clone(), self.cheap_llm().clone(), Some(notify_tx), diff --git a/src/agent/commands.rs b/src/agent/commands.rs index 365e0ad5..8a754062 100644 --- a/src/agent/commands.rs +++ b/src/agent/commands.rs @@ -232,6 +232,7 @@ impl Agent { let runner = crate::agent::HeartbeatRunner::new( crate::agent::HeartbeatConfig::default(), + crate::workspace::hygiene::HygieneConfig::default(), workspace.clone(), self.llm().clone(), ); diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index ff35955d..e495b3f3 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -31,6 +31,7 @@ use tokio::sync::mpsc; use crate::channels::OutgoingResponse; use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; use crate::workspace::Workspace; +use crate::workspace::hygiene::HygieneConfig; /// Configuration for the heartbeat runner. #[derive(Debug, Clone)] @@ -96,6 +97,7 @@ pub enum HeartbeatResult { /// Heartbeat runner for proactive periodic execution. pub struct HeartbeatRunner { config: HeartbeatConfig, + hygiene_config: HygieneConfig, workspace: Arc, llm: Arc, response_tx: Option>, @@ -106,11 +108,13 @@ impl HeartbeatRunner { /// Create a new heartbeat runner. pub fn new( config: HeartbeatConfig, + hygiene_config: HygieneConfig, workspace: Arc, llm: Arc, ) -> Self { Self { config, + hygiene_config, workspace, llm, response_tx: None, @@ -145,6 +149,22 @@ impl HeartbeatRunner { loop { interval.tick().await; + // Run memory hygiene in the background so it never delays the + // heartbeat checklist. Failures are logged inside run_if_due. + let hygiene_workspace = Arc::clone(&self.workspace); + let hygiene_config = self.hygiene_config.clone(); + tokio::spawn(async move { + let report = + crate::workspace::hygiene::run_if_due(&hygiene_workspace, &hygiene_config) + .await; + if report.had_work() { + tracing::info!( + daily_logs_deleted = report.daily_logs_deleted, + "heartbeat: memory hygiene deleted stale documents" + ); + } + }); + match self.check_heartbeat().await { HeartbeatResult::Ok => { tracing::debug!("Heartbeat OK"); @@ -332,11 +352,12 @@ fn strip_html_comments(content: &str) -> String { /// Returns a handle that can be used to stop the runner. pub fn spawn_heartbeat( config: HeartbeatConfig, + hygiene_config: HygieneConfig, workspace: Arc, llm: Arc, response_tx: Option>, ) -> tokio::task::JoinHandle<()> { - let mut runner = HeartbeatRunner::new(config, workspace, llm); + let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm); if let Some(tx) = response_tx { runner = runner.with_response_channel(tx); } diff --git a/src/config/hygiene.rs b/src/config/hygiene.rs new file mode 100644 index 00000000..f3d3f414 --- /dev/null +++ b/src/config/hygiene.rs @@ -0,0 +1,70 @@ +use crate::config::helpers::optional_env; +use crate::error::ConfigError; + +/// Memory hygiene configuration. +/// +/// Controls automatic cleanup of stale workspace documents. +/// Maps to `crate::workspace::hygiene::HygieneConfig`. +#[derive(Debug, Clone)] +pub struct HygieneConfig { + /// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true). + pub enabled: bool, + /// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_RETENTION_DAYS` (default: 30). + pub retention_days: u32, + /// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12). + pub cadence_hours: u32, +} + +impl Default for HygieneConfig { + fn default() -> Self { + Self { + enabled: true, + retention_days: 30, + cadence_hours: 12, + } + } +} + +impl HygieneConfig { + pub(crate) fn resolve() -> Result { + Ok(Self { + enabled: optional_env("MEMORY_HYGIENE_ENABLED")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "MEMORY_HYGIENE_ENABLED".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(true), + retention_days: optional_env("MEMORY_HYGIENE_RETENTION_DAYS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "MEMORY_HYGIENE_RETENTION_DAYS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(30), + cadence_hours: optional_env("MEMORY_HYGIENE_CADENCE_HOURS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "MEMORY_HYGIENE_CADENCE_HOURS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(12), + }) + } + + /// Convert to the workspace hygiene config, resolving the state directory + /// to the standard `~/.ironclaw` location. + pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig { + crate::workspace::hygiene::HygieneConfig { + enabled: self.enabled, + retention_days: self.retention_days, + cadence_hours: self.cadence_hours, + state_dir: dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".ironclaw"), + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 2d227723..24c823ef 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -12,6 +12,7 @@ mod database; mod embeddings; mod heartbeat; pub(crate) mod helpers; +mod hygiene; mod llm; mod routines; mod safety; @@ -34,6 +35,7 @@ pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig}; pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path}; pub use self::embeddings::EmbeddingsConfig; pub use self::heartbeat::HeartbeatConfig; +pub use self::hygiene::HygieneConfig; pub use self::llm::{ AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig, OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, @@ -67,6 +69,7 @@ pub struct Config { pub secrets: SecretsConfig, pub builder: BuilderModeConfig, pub heartbeat: HeartbeatConfig, + pub hygiene: HygieneConfig, pub routines: RoutineConfig, pub sandbox: SandboxModeConfig, pub claude_code: ClaudeCodeConfig, @@ -190,6 +193,7 @@ impl Config { secrets: SecretsConfig::resolve().await?, builder: BuilderModeConfig::resolve()?, heartbeat: HeartbeatConfig::resolve(settings)?, + hygiene: HygieneConfig::resolve()?, routines: RoutineConfig::resolve()?, sandbox: SandboxModeConfig::resolve()?, claude_code: ClaudeCodeConfig::resolve()?, diff --git a/src/main.rs b/src/main.rs index dbd8875d..e8a3e50c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1496,6 +1496,7 @@ async fn main() -> anyhow::Result<()> { deps, channels, Some(config.heartbeat.clone()), + Some(config.hygiene.clone()), Some(config.routines.clone()), Some(context_manager), Some(session_manager), diff --git a/tests/heartbeat_integration.rs b/tests/heartbeat_integration.rs index a4c07357..3fe0dc73 100644 --- a/tests/heartbeat_integration.rs +++ b/tests/heartbeat_integration.rs @@ -95,7 +95,8 @@ async fn test_heartbeat_end_to_end() { println!("[6/6] Running check_heartbeat()...\n"); let hb_config = ironclaw::agent::HeartbeatConfig::default(); - let runner = HeartbeatRunner::new(hb_config, workspace, llm); + let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default(); + let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm); let result = runner.check_heartbeat().await;