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_CHANNEL=cli
|
||||||
HEARTBEAT_NOTIFY_USER=default
|
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 settings
|
||||||
SAFETY_MAX_OUTPUT_LENGTH=100000
|
SAFETY_MAX_OUTPUT_LENGTH=100000
|
||||||
SAFETY_INJECTION_CHECK_ENABLED=true
|
SAFETY_INJECTION_CHECK_ENABLED=true
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub struct Agent {
|
|||||||
pub(super) session_manager: Arc<SessionManager>,
|
pub(super) session_manager: Arc<SessionManager>,
|
||||||
pub(super) context_monitor: ContextMonitor,
|
pub(super) context_monitor: ContextMonitor,
|
||||||
pub(super) heartbeat_config: Option<HeartbeatConfig>,
|
pub(super) heartbeat_config: Option<HeartbeatConfig>,
|
||||||
|
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
|
||||||
pub(super) routine_config: Option<RoutineConfig>,
|
pub(super) routine_config: Option<RoutineConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +94,13 @@ impl Agent {
|
|||||||
///
|
///
|
||||||
/// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing
|
/// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing
|
||||||
/// with external components (job tools, web gateway). Creates new ones if not provided.
|
/// with external components (job tools, web gateway). Creates new ones if not provided.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
deps: AgentDeps,
|
deps: AgentDeps,
|
||||||
channels: ChannelManager,
|
channels: ChannelManager,
|
||||||
heartbeat_config: Option<HeartbeatConfig>,
|
heartbeat_config: Option<HeartbeatConfig>,
|
||||||
|
hygiene_config: Option<crate::config::HygieneConfig>,
|
||||||
routine_config: Option<RoutineConfig>,
|
routine_config: Option<RoutineConfig>,
|
||||||
context_manager: Option<Arc<ContextManager>>,
|
context_manager: Option<Arc<ContextManager>>,
|
||||||
session_manager: Option<Arc<SessionManager>>,
|
session_manager: Option<Arc<SessionManager>>,
|
||||||
@@ -127,6 +130,7 @@ impl Agent {
|
|||||||
session_manager,
|
session_manager,
|
||||||
context_monitor: ContextMonitor::new(),
|
context_monitor: ContextMonitor::new(),
|
||||||
heartbeat_config,
|
heartbeat_config,
|
||||||
|
hygiene_config,
|
||||||
routine_config,
|
routine_config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,8 +362,15 @@ impl Agent {
|
|||||||
"Heartbeat enabled with {}s interval",
|
"Heartbeat enabled with {}s interval",
|
||||||
hb_config.interval_secs
|
hb_config.interval_secs
|
||||||
);
|
);
|
||||||
|
let hygiene = self
|
||||||
|
.hygiene_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|h| h.to_workspace_config())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Some(spawn_heartbeat(
|
Some(spawn_heartbeat(
|
||||||
config,
|
config,
|
||||||
|
hygiene,
|
||||||
workspace.clone(),
|
workspace.clone(),
|
||||||
self.cheap_llm().clone(),
|
self.cheap_llm().clone(),
|
||||||
Some(notify_tx),
|
Some(notify_tx),
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ impl Agent {
|
|||||||
|
|
||||||
let runner = crate::agent::HeartbeatRunner::new(
|
let runner = crate::agent::HeartbeatRunner::new(
|
||||||
crate::agent::HeartbeatConfig::default(),
|
crate::agent::HeartbeatConfig::default(),
|
||||||
|
crate::workspace::hygiene::HygieneConfig::default(),
|
||||||
workspace.clone(),
|
workspace.clone(),
|
||||||
self.llm().clone(),
|
self.llm().clone(),
|
||||||
);
|
);
|
||||||
|
|||||||
+22
-1
@@ -31,6 +31,7 @@ use tokio::sync::mpsc;
|
|||||||
use crate::channels::OutgoingResponse;
|
use crate::channels::OutgoingResponse;
|
||||||
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
|
use crate::workspace::hygiene::HygieneConfig;
|
||||||
|
|
||||||
/// Configuration for the heartbeat runner.
|
/// Configuration for the heartbeat runner.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -96,6 +97,7 @@ pub enum HeartbeatResult {
|
|||||||
/// Heartbeat runner for proactive periodic execution.
|
/// Heartbeat runner for proactive periodic execution.
|
||||||
pub struct HeartbeatRunner {
|
pub struct HeartbeatRunner {
|
||||||
config: HeartbeatConfig,
|
config: HeartbeatConfig,
|
||||||
|
hygiene_config: HygieneConfig,
|
||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||||
@@ -106,11 +108,13 @@ impl HeartbeatRunner {
|
|||||||
/// Create a new heartbeat runner.
|
/// Create a new heartbeat runner.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: HeartbeatConfig,
|
config: HeartbeatConfig,
|
||||||
|
hygiene_config: HygieneConfig,
|
||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
|
hygiene_config,
|
||||||
workspace,
|
workspace,
|
||||||
llm,
|
llm,
|
||||||
response_tx: None,
|
response_tx: None,
|
||||||
@@ -145,6 +149,22 @@ impl HeartbeatRunner {
|
|||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
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 {
|
match self.check_heartbeat().await {
|
||||||
HeartbeatResult::Ok => {
|
HeartbeatResult::Ok => {
|
||||||
tracing::debug!("Heartbeat 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.
|
/// Returns a handle that can be used to stop the runner.
|
||||||
pub fn spawn_heartbeat(
|
pub fn spawn_heartbeat(
|
||||||
config: HeartbeatConfig,
|
config: HeartbeatConfig,
|
||||||
|
hygiene_config: HygieneConfig,
|
||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||||
) -> tokio::task::JoinHandle<()> {
|
) -> 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 {
|
if let Some(tx) = response_tx {
|
||||||
runner = runner.with_response_channel(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 embeddings;
|
||||||
mod heartbeat;
|
mod heartbeat;
|
||||||
pub(crate) mod helpers;
|
pub(crate) mod helpers;
|
||||||
|
mod hygiene;
|
||||||
mod llm;
|
mod llm;
|
||||||
mod routines;
|
mod routines;
|
||||||
mod safety;
|
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::database::{DatabaseBackend, DatabaseConfig, default_libsql_path};
|
||||||
pub use self::embeddings::EmbeddingsConfig;
|
pub use self::embeddings::EmbeddingsConfig;
|
||||||
pub use self::heartbeat::HeartbeatConfig;
|
pub use self::heartbeat::HeartbeatConfig;
|
||||||
|
pub use self::hygiene::HygieneConfig;
|
||||||
pub use self::llm::{
|
pub use self::llm::{
|
||||||
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig,
|
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig,
|
||||||
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
|
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
|
||||||
@@ -67,6 +69,7 @@ pub struct Config {
|
|||||||
pub secrets: SecretsConfig,
|
pub secrets: SecretsConfig,
|
||||||
pub builder: BuilderModeConfig,
|
pub builder: BuilderModeConfig,
|
||||||
pub heartbeat: HeartbeatConfig,
|
pub heartbeat: HeartbeatConfig,
|
||||||
|
pub hygiene: HygieneConfig,
|
||||||
pub routines: RoutineConfig,
|
pub routines: RoutineConfig,
|
||||||
pub sandbox: SandboxModeConfig,
|
pub sandbox: SandboxModeConfig,
|
||||||
pub claude_code: ClaudeCodeConfig,
|
pub claude_code: ClaudeCodeConfig,
|
||||||
@@ -190,6 +193,7 @@ impl Config {
|
|||||||
secrets: SecretsConfig::resolve().await?,
|
secrets: SecretsConfig::resolve().await?,
|
||||||
builder: BuilderModeConfig::resolve()?,
|
builder: BuilderModeConfig::resolve()?,
|
||||||
heartbeat: HeartbeatConfig::resolve(settings)?,
|
heartbeat: HeartbeatConfig::resolve(settings)?,
|
||||||
|
hygiene: HygieneConfig::resolve()?,
|
||||||
routines: RoutineConfig::resolve()?,
|
routines: RoutineConfig::resolve()?,
|
||||||
sandbox: SandboxModeConfig::resolve()?,
|
sandbox: SandboxModeConfig::resolve()?,
|
||||||
claude_code: ClaudeCodeConfig::resolve()?,
|
claude_code: ClaudeCodeConfig::resolve()?,
|
||||||
|
|||||||
@@ -1496,6 +1496,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
deps,
|
deps,
|
||||||
channels,
|
channels,
|
||||||
Some(config.heartbeat.clone()),
|
Some(config.heartbeat.clone()),
|
||||||
|
Some(config.hygiene.clone()),
|
||||||
Some(config.routines.clone()),
|
Some(config.routines.clone()),
|
||||||
Some(context_manager),
|
Some(context_manager),
|
||||||
Some(session_manager),
|
Some(session_manager),
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ async fn test_heartbeat_end_to_end() {
|
|||||||
println!("[6/6] Running check_heartbeat()...\n");
|
println!("[6/6] Running check_heartbeat()...\n");
|
||||||
|
|
||||||
let hb_config = ironclaw::agent::HeartbeatConfig::default();
|
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;
|
let result = runner.check_heartbeat().await;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user