use std::time::Instant;
use chrono::Utc;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use crate::{
error::Result,
intelligence::{
gardening::{GardenAction, GardenConfig, MemoryGardener},
proactive::GapDetector,
},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub check_interval_secs: u64,
pub garden_interval_secs: u64,
pub max_actions_per_cycle: usize,
pub workspace: String,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
check_interval_secs: 300,
garden_interval_secs: 3600,
max_actions_per_cycle: 10,
workspace: "default".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentState {
pub running: bool,
pub cycles: u64,
pub last_garden_at: Option<String>,
pub last_acquisition_at: Option<String>,
pub total_actions: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentAction {
Prune {
memory_id: i64,
reason: String,
},
Merge {
source_ids: Vec<i64>,
},
Archive {
memory_id: i64,
},
Suggest {
hint: String,
priority: u8,
},
Garden {
report_summary: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMetrics {
pub cycles: u64,
pub total_actions: u64,
pub memories_pruned: u64,
pub memories_merged: u64,
pub memories_archived: u64,
pub suggestions_made: u64,
pub gardens_run: u64,
pub uptime_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CycleResult {
pub actions: Vec<AgentAction>,
pub cycle_number: u64,
pub duration_ms: u64,
}
pub struct MemoryAgent {
pub config: AgentConfig,
pub state: AgentState,
started_at: Option<Instant>,
memories_pruned: u64,
memories_merged: u64,
memories_archived: u64,
suggestions_made: u64,
gardens_run: u64,
}
impl MemoryAgent {
pub fn new(config: AgentConfig) -> Self {
Self {
config,
state: AgentState {
running: false,
cycles: 0,
last_garden_at: None,
last_acquisition_at: None,
total_actions: 0,
},
started_at: None,
memories_pruned: 0,
memories_merged: 0,
memories_archived: 0,
suggestions_made: 0,
gardens_run: 0,
}
}
pub fn start(&mut self) {
self.state.running = true;
self.started_at = Some(Instant::now());
}
pub fn stop(&mut self) {
self.state.running = false;
}
pub fn is_running(&self) -> bool {
self.state.running
}
pub fn tick(&mut self, conn: &Connection) -> Result<CycleResult> {
let cycle_start = Instant::now();
let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let workspace = self.config.workspace.clone();
let max = self.config.max_actions_per_cycle;
let garden_preview = MemoryGardener::new(GardenConfig {
dry_run: true,
..GardenConfig::default()
})
.garden(conn, &workspace)?;
let gap_detector = GapDetector::new();
let acquisition_suggestions = gap_detector.suggest_acquisitions(conn, &workspace, 0)?;
let mut decided: Vec<AgentAction> = Vec::new();
for action in &garden_preview.actions {
if decided.len() >= max {
break;
}
if let GardenAction::Prune { memory_id, reason } = action {
decided.push(AgentAction::Prune {
memory_id: *memory_id,
reason: reason.clone(),
});
}
}
for action in &garden_preview.actions {
if decided.len() >= max {
break;
}
if let GardenAction::Merge { source_ids, .. } = action {
decided.push(AgentAction::Merge {
source_ids: source_ids.clone(),
});
}
}
for action in &garden_preview.actions {
if decided.len() >= max {
break;
}
if let GardenAction::Archive { memory_id } = action {
decided.push(AgentAction::Archive {
memory_id: *memory_id,
});
}
}
if decided.len() < max && self.should_garden() {
let summary = format!(
"Gardening workspace '{}': {} pruned, {} merged, {} archived",
workspace,
garden_preview.memories_pruned,
garden_preview.memories_merged,
garden_preview.memories_archived,
);
decided.push(AgentAction::Garden {
report_summary: summary,
});
}
for suggestion in &acquisition_suggestions {
if decided.len() >= max {
break;
}
decided.push(AgentAction::Suggest {
hint: suggestion.content_hint.clone(),
priority: suggestion.priority,
});
}
for action in &decided {
match action {
AgentAction::Prune { .. } => self.memories_pruned += 1,
AgentAction::Merge { .. } => self.memories_merged += 1,
AgentAction::Archive { .. } => self.memories_archived += 1,
AgentAction::Suggest { .. } => self.suggestions_made += 1,
AgentAction::Garden { .. } => {
self.gardens_run += 1;
self.state.last_garden_at = Some(now_str.clone());
}
}
}
if !acquisition_suggestions.is_empty() || decided.iter().any(|a| matches!(a, AgentAction::Suggest { .. })) {
self.state.last_acquisition_at = Some(now_str.clone());
}
self.state.cycles += 1;
self.state.total_actions += decided.len() as u64;
let duration_ms = cycle_start.elapsed().as_millis() as u64;
Ok(CycleResult {
actions: decided,
cycle_number: self.state.cycles,
duration_ms,
})
}
pub fn should_garden(&self) -> bool {
match &self.state.last_garden_at {
None => true,
Some(last_str) => {
if let Ok(last_dt) = chrono::DateTime::parse_from_rfc3339(last_str) {
let now = Utc::now();
let elapsed = (now.timestamp() - last_dt.timestamp()).max(0) as u64;
elapsed >= self.config.garden_interval_secs
} else {
true
}
}
}
}
pub fn metrics(&self) -> AgentMetrics {
let uptime_secs = self
.started_at
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
AgentMetrics {
cycles: self.state.cycles,
total_actions: self.state.total_actions,
memories_pruned: self.memories_pruned,
memories_merged: self.memories_merged,
memories_archived: self.memories_archived,
suggestions_made: self.suggestions_made,
gardens_run: self.gardens_run,
uptime_secs,
}
}
pub fn configure(&mut self, new_config: AgentConfig) {
self.config = new_config;
}
pub fn status(&self) -> AgentState {
self.state.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn setup_conn() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
memory_type TEXT NOT NULL DEFAULT 'note',
workspace TEXT NOT NULL DEFAULT 'default',
importance REAL NOT NULL DEFAULT 0.5,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_accessed_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_id INTEGER NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(memory_id) REFERENCES memories(id)
);",
)
.expect("create schema");
conn
}
fn insert_memory(
conn: &Connection,
content: &str,
importance: f32,
updated_at: &str,
workspace: &str,
) -> i64 {
conn.execute(
"INSERT INTO memories (content, importance, updated_at, created_at, workspace)
VALUES (?1, ?2, ?3, ?3, ?4)",
rusqlite::params![content, importance as f64, updated_at, workspace],
)
.expect("insert");
conn.last_insert_rowid()
}
#[test]
fn test_new_agent_not_running() {
let agent = MemoryAgent::new(AgentConfig::default());
assert!(!agent.is_running(), "new agent should not be running");
assert_eq!(agent.state.cycles, 0);
assert_eq!(agent.state.total_actions, 0);
assert!(agent.state.last_garden_at.is_none());
assert!(agent.state.last_acquisition_at.is_none());
}
#[test]
fn test_start_stop_lifecycle() {
let mut agent = MemoryAgent::new(AgentConfig::default());
agent.start();
assert!(agent.is_running(), "agent should be running after start()");
agent.stop();
assert!(!agent.is_running(), "agent should not be running after stop()");
let m = agent.metrics();
assert!(m.uptime_secs < u64::MAX, "uptime should be a valid value");
}
#[test]
fn test_tick_empty_workspace() {
let conn = setup_conn();
let mut agent = MemoryAgent::new(AgentConfig {
workspace: "empty".to_string(),
..AgentConfig::default()
});
let result = agent.tick(&conn).expect("tick");
assert_eq!(
result.cycle_number, 1,
"cycle_number should be 1 after first tick"
);
for action in &result.actions {
assert!(
!matches!(action, AgentAction::Prune { .. }),
"should not prune in empty workspace"
);
assert!(
!matches!(action, AgentAction::Merge { .. }),
"should not merge in empty workspace"
);
assert!(
!matches!(action, AgentAction::Archive { .. }),
"should not archive in empty workspace"
);
}
}
#[test]
fn test_tick_increments_state() {
let conn = setup_conn();
let mut agent = MemoryAgent::new(AgentConfig::default());
agent.tick(&conn).expect("tick 1");
assert_eq!(agent.state.cycles, 1);
agent.tick(&conn).expect("tick 2");
assert_eq!(agent.state.cycles, 2);
let _ = agent.state.total_actions; }
#[test]
fn test_tick_produces_prune_actions() {
let conn = setup_conn();
insert_memory(
&conn,
"completely stale irrelevant note",
0.01,
"2000-01-01T00:00:00Z",
"default",
);
let mut agent = MemoryAgent::new(AgentConfig::default());
let result = agent.tick(&conn).expect("tick");
let has_prune = result
.actions
.iter()
.any(|a| matches!(a, AgentAction::Prune { .. }));
assert!(has_prune, "expected at least one Prune action for stale memory");
}
#[test]
fn test_metrics_track_cycles() {
let conn = setup_conn();
let mut agent = MemoryAgent::new(AgentConfig::default());
agent.start();
for _ in 0..3 {
agent.tick(&conn).expect("tick");
}
let m = agent.metrics();
assert_eq!(m.cycles, 3, "metrics.cycles should equal number of ticks");
assert_eq!(m.total_actions, agent.state.total_actions);
assert!(m.uptime_secs < 60, "uptime should be seconds, not huge");
}
#[test]
fn test_configure_updates_config() {
let mut agent = MemoryAgent::new(AgentConfig::default());
assert_eq!(agent.config.check_interval_secs, 300);
assert_eq!(agent.config.max_actions_per_cycle, 10);
agent.configure(AgentConfig {
check_interval_secs: 60,
garden_interval_secs: 600,
max_actions_per_cycle: 5,
workspace: "my-ws".to_string(),
});
assert_eq!(agent.config.check_interval_secs, 60);
assert_eq!(agent.config.garden_interval_secs, 600);
assert_eq!(agent.config.max_actions_per_cycle, 5);
assert_eq!(agent.config.workspace, "my-ws");
}
#[test]
fn test_should_garden_timing() {
let mut agent = MemoryAgent::new(AgentConfig {
garden_interval_secs: 3600,
..AgentConfig::default()
});
assert!(agent.should_garden(), "should garden when no previous run");
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
agent.state.last_garden_at = Some(now);
assert!(
!agent.should_garden(),
"should NOT garden immediately after a run"
);
let past = "2000-01-01T00:00:00Z".to_string();
agent.state.last_garden_at = Some(past);
assert!(
agent.should_garden(),
"should garden when interval has elapsed"
);
}
#[test]
fn test_max_actions_per_cycle_capped() {
let conn = setup_conn();
for i in 0..20 {
insert_memory(
&conn,
&format!("stale note number {}", i),
0.01,
"2000-01-01T00:00:00Z",
"default",
);
}
let mut agent = MemoryAgent::new(AgentConfig {
max_actions_per_cycle: 3,
..AgentConfig::default()
});
let result = agent.tick(&conn).expect("tick");
assert!(
result.actions.len() <= 3,
"actions should be capped at max_actions_per_cycle=3, got {}",
result.actions.len()
);
}
#[test]
fn test_status_returns_state_snapshot() {
let mut agent = MemoryAgent::new(AgentConfig::default());
agent.start();
let status = agent.status();
assert!(status.running);
assert_eq!(status.cycles, agent.state.cycles);
assert_eq!(status.total_actions, agent.state.total_actions);
}
#[test]
fn test_garden_action_in_first_tick() {
let conn = setup_conn();
let mut agent = MemoryAgent::new(AgentConfig::default());
let result = agent.tick(&conn).expect("tick");
let has_garden = result
.actions
.iter()
.any(|a| matches!(a, AgentAction::Garden { .. }));
assert!(has_garden, "first tick should include a Garden action");
assert!(
agent.state.last_garden_at.is_some(),
"last_garden_at should be set after a Garden action"
);
}
#[test]
fn test_uptime_zero_when_not_started() {
let agent = MemoryAgent::new(AgentConfig::default());
let m = agent.metrics();
assert_eq!(m.uptime_secs, 0, "uptime should be 0 before start()");
}
}