mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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 <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Illia Polosukhin
parent
356f56f77c
commit
fa64df05ff
@@ -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
|
||||
|
||||
@@ -85,6 +85,7 @@ pub struct Agent {
|
||||
pub(super) session_manager: Arc<SessionManager>,
|
||||
pub(super) context_monitor: ContextMonitor,
|
||||
pub(super) heartbeat_config: Option<HeartbeatConfig>,
|
||||
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
|
||||
pub(super) routine_config: Option<RoutineConfig>,
|
||||
}
|
||||
|
||||
@@ -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<HeartbeatConfig>,
|
||||
hygiene_config: Option<crate::config::HygieneConfig>,
|
||||
routine_config: Option<RoutineConfig>,
|
||||
context_manager: Option<Arc<ContextManager>>,
|
||||
session_manager: Option<Arc<SessionManager>>,
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
+22
-1
@@ -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<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
@@ -106,11 +108,13 @@ impl HeartbeatRunner {
|
||||
/// Create a new heartbeat runner.
|
||||
pub fn new(
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
) -> 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<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
) -> 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);
|
||||
}
|
||||
|
||||
@@ -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<Self, ConfigError> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user