mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 01:19:34 +00:00
Add workspace and memory system (OpenClaw-inspired)
Implements persistent memory for agents with hybrid search: - Database-backed workspace with PostgreSQL (not filesystem) - Memory documents: MEMORY.md, daily logs, identity files - Chunked content with FTS (tsvector) + vector (pgvector) indexes - Reciprocal Rank Fusion (RRF) for hybrid search combining BM25 and semantic - Memory tools: memory_search, memory_write, memory_read - Proactive heartbeat system for periodic execution (30 min default) - OpenAI embeddings provider (text-embedding-3-small) Key patterns from OpenClaw: - "Memory is files, not RAM" - explicit persistence required - Two-tier memory: daily logs (raw) + curated MEMORY.md - Session isolation via user_id/agent_id scoping Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
8c38566378
commit
4e238e60ac
@@ -0,0 +1,320 @@
|
||||
//! Proactive heartbeat system for periodic execution.
|
||||
//!
|
||||
//! The heartbeat runner executes periodically (default: every 30 minutes) and:
|
||||
//! 1. Reads the HEARTBEAT.md checklist
|
||||
//! 2. Runs an agent turn to process the checklist
|
||||
//! 3. Reports any findings to the configured channel
|
||||
//!
|
||||
//! If nothing needs attention, the agent replies "HEARTBEAT_OK" and no
|
||||
//! message is sent to the user.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Create a HEARTBEAT.md in the workspace with a checklist of things to monitor:
|
||||
//!
|
||||
//! ```markdown
|
||||
//! # Heartbeat Checklist
|
||||
//!
|
||||
//! - [ ] Check for unread emails
|
||||
//! - [ ] Review calendar for upcoming events
|
||||
//! - [ ] Check project build status
|
||||
//! ```
|
||||
//!
|
||||
//! The agent will process this checklist on each heartbeat and only notify
|
||||
//! if action is needed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::error::WorkspaceError;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Configuration for the heartbeat runner.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeartbeatConfig {
|
||||
/// Interval between heartbeat checks.
|
||||
pub interval: Duration,
|
||||
/// Whether heartbeat is enabled.
|
||||
pub enabled: bool,
|
||||
/// Maximum consecutive failures before disabling.
|
||||
pub max_failures: u32,
|
||||
/// User ID to notify on heartbeat findings.
|
||||
pub notify_user_id: Option<String>,
|
||||
/// Channel to notify on heartbeat findings.
|
||||
pub notify_channel: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interval: Duration::from_secs(30 * 60), // 30 minutes
|
||||
enabled: true,
|
||||
max_failures: 3,
|
||||
notify_user_id: None,
|
||||
notify_channel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HeartbeatConfig {
|
||||
/// Create a config with a specific interval.
|
||||
pub fn with_interval(mut self, interval: Duration) -> Self {
|
||||
self.interval = interval;
|
||||
self
|
||||
}
|
||||
|
||||
/// Disable heartbeat.
|
||||
pub fn disabled(mut self) -> Self {
|
||||
self.enabled = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the notification target.
|
||||
pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self {
|
||||
self.notify_user_id = Some(user_id.into());
|
||||
self.notify_channel = Some(channel.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a heartbeat check.
|
||||
#[derive(Debug)]
|
||||
pub enum HeartbeatResult {
|
||||
/// Nothing needs attention.
|
||||
Ok,
|
||||
/// Something needs attention, with the message to send.
|
||||
NeedsAttention(String),
|
||||
/// Heartbeat was skipped (no checklist or disabled).
|
||||
Skipped,
|
||||
/// Heartbeat failed.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Heartbeat runner for proactive periodic execution.
|
||||
pub struct HeartbeatRunner {
|
||||
config: HeartbeatConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
|
||||
impl HeartbeatRunner {
|
||||
/// Create a new heartbeat runner.
|
||||
pub fn new(
|
||||
config: HeartbeatConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
workspace,
|
||||
llm,
|
||||
response_tx: None,
|
||||
consecutive_failures: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the response channel for notifications.
|
||||
pub fn with_response_channel(mut self, tx: mpsc::Sender<OutgoingResponse>) -> Self {
|
||||
self.response_tx = Some(tx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run the heartbeat loop.
|
||||
///
|
||||
/// This runs forever, checking periodically based on the configured interval.
|
||||
pub async fn run(&mut self) {
|
||||
if !self.config.enabled {
|
||||
tracing::info!("Heartbeat is disabled, not starting loop");
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Starting heartbeat loop with interval {:?}",
|
||||
self.config.interval
|
||||
);
|
||||
|
||||
let mut interval = tokio::time::interval(self.config.interval);
|
||||
// Don't run immediately on startup
|
||||
interval.tick().await;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match self.check_heartbeat().await {
|
||||
HeartbeatResult::Ok => {
|
||||
tracing::debug!("Heartbeat OK");
|
||||
self.consecutive_failures = 0;
|
||||
}
|
||||
HeartbeatResult::NeedsAttention(message) => {
|
||||
tracing::info!("Heartbeat needs attention: {}", message);
|
||||
self.consecutive_failures = 0;
|
||||
self.send_notification(&message).await;
|
||||
}
|
||||
HeartbeatResult::Skipped => {
|
||||
tracing::debug!("Heartbeat skipped");
|
||||
}
|
||||
HeartbeatResult::Failed(error) => {
|
||||
tracing::error!("Heartbeat failed: {}", error);
|
||||
self.consecutive_failures += 1;
|
||||
|
||||
if self.consecutive_failures >= self.config.max_failures {
|
||||
tracing::error!(
|
||||
"Heartbeat disabled after {} consecutive failures",
|
||||
self.consecutive_failures
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single heartbeat check.
|
||||
pub async fn check_heartbeat(&self) -> HeartbeatResult {
|
||||
// Get the heartbeat checklist
|
||||
let checklist = match self.workspace.heartbeat_checklist().await {
|
||||
Ok(Some(content)) if !content.trim().is_empty() => content,
|
||||
Ok(_) => return HeartbeatResult::Skipped,
|
||||
Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)),
|
||||
};
|
||||
|
||||
// Build the heartbeat prompt
|
||||
let prompt = format!(
|
||||
"Read the HEARTBEAT.md checklist below and follow it strictly. \
|
||||
Do not infer or repeat old tasks. Check each item and report findings.\n\
|
||||
\n\
|
||||
If nothing needs attention, reply EXACTLY with: HEARTBEAT_OK\n\
|
||||
\n\
|
||||
If something needs attention, provide a concise summary of what needs action.\n\
|
||||
\n\
|
||||
## HEARTBEAT.md\n\
|
||||
\n\
|
||||
{}",
|
||||
checklist
|
||||
);
|
||||
|
||||
// Get the system prompt for context
|
||||
let system_prompt = match self.workspace.system_prompt().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to get system prompt for heartbeat: {}", e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Run the agent turn
|
||||
let messages = if system_prompt.is_empty() {
|
||||
vec![ChatMessage::user(&prompt)]
|
||||
} else {
|
||||
vec![
|
||||
ChatMessage::system(&system_prompt),
|
||||
ChatMessage::user(&prompt),
|
||||
]
|
||||
};
|
||||
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(1024)
|
||||
.with_temperature(0.3); // Lower temperature for more focused responses
|
||||
|
||||
let response = match self.llm.complete(request).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
|
||||
};
|
||||
|
||||
let content = response.content.trim();
|
||||
|
||||
// Check if nothing needs attention
|
||||
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
||||
return HeartbeatResult::Ok;
|
||||
}
|
||||
|
||||
HeartbeatResult::NeedsAttention(content.to_string())
|
||||
}
|
||||
|
||||
/// Send a notification about heartbeat findings.
|
||||
async fn send_notification(&self, message: &str) {
|
||||
let Some(ref tx) = self.response_tx else {
|
||||
tracing::debug!("No response channel configured for heartbeat notifications");
|
||||
return;
|
||||
};
|
||||
|
||||
let response = OutgoingResponse {
|
||||
content: format!("🔔 **Heartbeat Alert**\n\n{}", message),
|
||||
thread_id: None,
|
||||
metadata: serde_json::json!({
|
||||
"source": "heartbeat",
|
||||
}),
|
||||
};
|
||||
|
||||
if let Err(e) = tx.send(response).await {
|
||||
tracing::error!("Failed to send heartbeat notification: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the heartbeat runner as a background task.
|
||||
///
|
||||
/// Returns a handle that can be used to stop the runner.
|
||||
pub fn spawn_heartbeat(
|
||||
config: HeartbeatConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let mut runner = HeartbeatRunner::new(config, workspace, llm);
|
||||
if let Some(tx) = response_tx {
|
||||
runner = runner.with_response_channel(tx);
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
runner.run().await;
|
||||
})
|
||||
}
|
||||
|
||||
/// Update heartbeat state in the database.
|
||||
pub async fn update_heartbeat_state(
|
||||
workspace: &Workspace,
|
||||
last_run: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<(), WorkspaceError> {
|
||||
// This would update the heartbeat_state table
|
||||
// For now, we just log
|
||||
tracing::debug!(
|
||||
"Heartbeat state updated for user {} at {}",
|
||||
workspace.user_id(),
|
||||
last_run
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_config_defaults() {
|
||||
let config = HeartbeatConfig::default();
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.interval, Duration::from_secs(30 * 60));
|
||||
assert_eq!(config.max_failures, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_config_builders() {
|
||||
let config = HeartbeatConfig::default()
|
||||
.with_interval(Duration::from_secs(60))
|
||||
.with_notify("user1", "telegram");
|
||||
|
||||
assert_eq!(config.interval, Duration::from_secs(60));
|
||||
assert_eq!(config.notify_user_id, Some("user1".to_string()));
|
||||
assert_eq!(config.notify_channel, Some("telegram".to_string()));
|
||||
|
||||
let disabled = HeartbeatConfig::default().disabled();
|
||||
assert!(!disabled.enabled);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,17 @@
|
||||
//! - Job scheduling and execution
|
||||
//! - Tool invocation with safety
|
||||
//! - Self-repair for stuck jobs
|
||||
//! - Proactive heartbeat execution
|
||||
|
||||
mod agent_loop;
|
||||
mod heartbeat;
|
||||
mod router;
|
||||
mod scheduler;
|
||||
mod self_repair;
|
||||
mod worker;
|
||||
|
||||
pub use agent_loop::Agent;
|
||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||
pub use router::{MessageIntent, Router};
|
||||
pub use scheduler::Scheduler;
|
||||
pub use self_repair::{RepairResult, RepairTask, SelfRepair, StuckJob};
|
||||
|
||||
Reference in New Issue
Block a user