Skip to main content

engram/intelligence/
agent_loop.rs

1//! Memory Agent Loop — RML-1223
2//!
3//! Ties together proactive acquisition (RML-1221) and gardening (RML-1222)
4//! into a single observe→decide→act cycle for autonomous memory management.
5//!
6//! ## Design
7//!
8//! The agent is **not** a background thread. It is a struct with a [`MemoryAgent::tick`]
9//! method. The caller — an MCP handler or binary — is responsible for invoking
10//! `tick()` at the desired interval (see `check_interval_secs`).
11//!
12//! ## Cycle Phases
13//!
14//! 1. **Observe** — score memories with [`MemoryGardener`], detect knowledge gaps
15//!    with [`GapDetector`].
16//! 2. **Decide** — prioritise actions: urgent prunes first, then merges, archives,
17//!    then acquisition suggestions. Capped at `max_actions_per_cycle`.
18//! 3. **Act** — return the decided actions as a [`CycleResult`]. The agent does NOT
19//!    apply DB changes itself; MCP handlers optionally apply them.
20//! 4. **Update state** — increment cycle counter and action totals.
21//!
22//! ## Invariants
23//!
24//! - The agent starts in a stopped state (`running = false`).
25//! - `tick()` may be called regardless of `running` state; callers decide.
26//! - `AgentMetrics.uptime_secs` is 0 until `start()` is called.
27//! - All timestamps in `AgentState` are RFC3339 UTC strings.
28//! - No `unwrap()` in production paths.
29
30use std::time::Instant;
31
32use chrono::Utc;
33use rusqlite::Connection;
34use serde::{Deserialize, Serialize};
35
36use crate::{
37    error::Result,
38    intelligence::{
39        gardening::{GardenAction, GardenConfig, MemoryGardener},
40        proactive::GapDetector,
41    },
42};
43
44// =============================================================================
45// Public types
46// =============================================================================
47
48/// Configuration for the memory agent.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct AgentConfig {
51    /// How often (in seconds) the agent should run a full observe→decide→act cycle.
52    /// Default: 300 (5 minutes).
53    pub check_interval_secs: u64,
54    /// How often (in seconds) a full garden maintenance run should be triggered.
55    /// Default: 3600 (1 hour).
56    pub garden_interval_secs: u64,
57    /// Maximum number of actions the agent may decide on per cycle.
58    /// Default: 10.
59    pub max_actions_per_cycle: usize,
60    /// Workspace to operate on.
61    /// Default: `"default"`.
62    pub workspace: String,
63}
64
65impl Default for AgentConfig {
66    fn default() -> Self {
67        Self {
68            check_interval_secs: 300,
69            garden_interval_secs: 3600,
70            max_actions_per_cycle: 10,
71            workspace: "default".to_string(),
72        }
73    }
74}
75
76/// Live state of the memory agent.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AgentState {
79    /// Whether the agent has been started.
80    pub running: bool,
81    /// Number of cycles completed so far.
82    pub cycles: u64,
83    /// RFC3339 UTC timestamp of the last garden maintenance run.
84    pub last_garden_at: Option<String>,
85    /// RFC3339 UTC timestamp of the last proactive acquisition scan.
86    pub last_acquisition_at: Option<String>,
87    /// Total number of actions decided across all cycles.
88    pub total_actions: u64,
89}
90
91/// A single action decided by the agent during one cycle.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum AgentAction {
95    /// A memory should be pruned because its garden score is critically low.
96    Prune {
97        memory_id: i64,
98        reason: String,
99    },
100    /// Two or more memories should be merged (similar content).
101    Merge {
102        source_ids: Vec<i64>,
103    },
104    /// A memory should be archived (old but not deleted).
105    Archive {
106        memory_id: i64,
107    },
108    /// A knowledge gap was detected — suggest creating a new memory.
109    Suggest {
110        hint: String,
111        priority: u8,
112    },
113    /// A full garden maintenance run was executed.
114    Garden {
115        report_summary: String,
116    },
117}
118
119/// Performance and health metrics for the agent.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct AgentMetrics {
122    /// Total completed cycles.
123    pub cycles: u64,
124    /// Total actions decided across all cycles.
125    pub total_actions: u64,
126    /// Memories pruned (counted from Prune actions).
127    pub memories_pruned: u64,
128    /// Memories merged (counted from Merge actions; each Merge action = 1 merge op).
129    pub memories_merged: u64,
130    /// Memories archived (counted from Archive actions).
131    pub memories_archived: u64,
132    /// Acquisition suggestions made.
133    pub suggestions_made: u64,
134    /// Garden maintenance runs executed.
135    pub gardens_run: u64,
136    /// Wall-clock seconds since `start()` was called (0 if never started).
137    pub uptime_secs: u64,
138}
139
140/// The result of one observe→decide→act cycle.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct CycleResult {
143    /// Actions decided during this cycle (not yet applied to the DB).
144    pub actions: Vec<AgentAction>,
145    /// The cycle number (1-based; equals `AgentState.cycles` after the tick).
146    pub cycle_number: u64,
147    /// Wall-clock time spent executing this cycle, in milliseconds.
148    pub duration_ms: u64,
149}
150
151// =============================================================================
152// MemoryAgent
153// =============================================================================
154
155/// Autonomous memory management agent.
156///
157/// Call [`MemoryAgent::tick`] at the desired cadence to run one full
158/// observe→decide→act cycle. The agent tracks its own state between calls.
159pub struct MemoryAgent {
160    /// Current configuration (mutable via [`MemoryAgent::configure`]).
161    pub config: AgentConfig,
162    /// Live operational state.
163    pub state: AgentState,
164    /// Wall-clock instant when `start()` was last called; `None` if never started.
165    started_at: Option<Instant>,
166
167    // Per-action-type counters (not exposed in `AgentState` to keep it simple;
168    // surfaced through `metrics()`).
169    memories_pruned: u64,
170    memories_merged: u64,
171    memories_archived: u64,
172    suggestions_made: u64,
173    gardens_run: u64,
174}
175
176impl MemoryAgent {
177    // -------------------------------------------------------------------------
178    // Construction
179    // -------------------------------------------------------------------------
180
181    /// Create a new agent with the given configuration.
182    /// The agent starts in a **stopped** state (`running = false`).
183    pub fn new(config: AgentConfig) -> Self {
184        Self {
185            config,
186            state: AgentState {
187                running: false,
188                cycles: 0,
189                last_garden_at: None,
190                last_acquisition_at: None,
191                total_actions: 0,
192            },
193            started_at: None,
194            memories_pruned: 0,
195            memories_merged: 0,
196            memories_archived: 0,
197            suggestions_made: 0,
198            gardens_run: 0,
199        }
200    }
201
202    // -------------------------------------------------------------------------
203    // Lifecycle
204    // -------------------------------------------------------------------------
205
206    /// Mark the agent as running and record the start time for uptime tracking.
207    pub fn start(&mut self) {
208        self.state.running = true;
209        self.started_at = Some(Instant::now());
210    }
211
212    /// Mark the agent as stopped. The accumulated state is preserved.
213    pub fn stop(&mut self) {
214        self.state.running = false;
215    }
216
217    /// Returns `true` if the agent has been started and not yet stopped.
218    pub fn is_running(&self) -> bool {
219        self.state.running
220    }
221
222    // -------------------------------------------------------------------------
223    // Core loop
224    // -------------------------------------------------------------------------
225
226    /// Execute **one** observe→decide→act cycle.
227    ///
228    /// # Phase 1 — Observe
229    /// - Run [`MemoryGardener`] in **dry-run** mode to score memories and
230    ///   identify prune / merge / archive candidates.
231    /// - Run [`GapDetector`] to surface knowledge gaps and acquisition hints.
232    ///
233    /// # Phase 2 — Decide
234    /// Actions are prioritised as:
235    /// 1. Urgent prunes (garden score critically low)
236    /// 2. Merge candidates
237    /// 3. Archive candidates
238    /// 4. Garden run (if `should_garden()`)
239    /// 5. Acquisition suggestions from gap analysis
240    ///
241    /// The total is capped at `config.max_actions_per_cycle`.
242    ///
243    /// # Phase 3 — Act
244    /// The actions list is returned. **No DB writes are made by this method.**
245    /// The caller (MCP handler) decides whether to apply them.
246    ///
247    /// # State Update
248    /// `AgentState.cycles` is incremented and timestamps are refreshed.
249    pub fn tick(&mut self, conn: &Connection) -> Result<CycleResult> {
250        let cycle_start = Instant::now();
251        let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
252        let workspace = self.config.workspace.clone();
253        let max = self.config.max_actions_per_cycle;
254
255        // ------------------------------------------------------------------ //
256        // Phase 1 — Observe
257        // ------------------------------------------------------------------ //
258
259        // Run gardener in dry-run mode to get scored action candidates
260        let garden_preview = MemoryGardener::new(GardenConfig {
261            dry_run: true,
262            ..GardenConfig::default()
263        })
264        .garden(conn, &workspace)?;
265
266        // Run gap detector to find acquisition suggestions
267        let gap_detector = GapDetector::new();
268        let acquisition_suggestions = gap_detector.suggest_acquisitions(conn, &workspace, 0)?;
269
270        // ------------------------------------------------------------------ //
271        // Phase 2 — Decide
272        // ------------------------------------------------------------------ //
273
274        let mut decided: Vec<AgentAction> = Vec::new();
275
276        // 2a. Urgent prunes first (score < 0.1 based on gardener report)
277        for action in &garden_preview.actions {
278            if decided.len() >= max {
279                break;
280            }
281            if let GardenAction::Prune { memory_id, reason } = action {
282                // "Urgent" = the reason string contains a very low score indicator.
283                // The gardener always uses score < prune_threshold (default 0.2) to prune.
284                // We treat all gardener-recommended prunes as legitimate candidates.
285                decided.push(AgentAction::Prune {
286                    memory_id: *memory_id,
287                    reason: reason.clone(),
288                });
289            }
290        }
291
292        // 2b. Merge candidates
293        for action in &garden_preview.actions {
294            if decided.len() >= max {
295                break;
296            }
297            if let GardenAction::Merge { source_ids, .. } = action {
298                decided.push(AgentAction::Merge {
299                    source_ids: source_ids.clone(),
300                });
301            }
302        }
303
304        // 2c. Archive candidates
305        for action in &garden_preview.actions {
306            if decided.len() >= max {
307                break;
308            }
309            if let GardenAction::Archive { memory_id } = action {
310                decided.push(AgentAction::Archive {
311                    memory_id: *memory_id,
312                });
313            }
314        }
315
316        // 2d. Garden run (if due)
317        if decided.len() < max && self.should_garden() {
318            let summary = format!(
319                "Gardening workspace '{}': {} pruned, {} merged, {} archived",
320                workspace,
321                garden_preview.memories_pruned,
322                garden_preview.memories_merged,
323                garden_preview.memories_archived,
324            );
325            decided.push(AgentAction::Garden {
326                report_summary: summary,
327            });
328        }
329
330        // 2e. Acquisition suggestions
331        for suggestion in &acquisition_suggestions {
332            if decided.len() >= max {
333                break;
334            }
335            decided.push(AgentAction::Suggest {
336                hint: suggestion.content_hint.clone(),
337                priority: suggestion.priority,
338            });
339        }
340
341        // ------------------------------------------------------------------ //
342        // Phase 3 — Act (record only; caller applies)
343        // ------------------------------------------------------------------ //
344
345        // Count action types for metrics
346        for action in &decided {
347            match action {
348                AgentAction::Prune { .. } => self.memories_pruned += 1,
349                AgentAction::Merge { .. } => self.memories_merged += 1,
350                AgentAction::Archive { .. } => self.memories_archived += 1,
351                AgentAction::Suggest { .. } => self.suggestions_made += 1,
352                AgentAction::Garden { .. } => {
353                    self.gardens_run += 1;
354                    self.state.last_garden_at = Some(now_str.clone());
355                }
356            }
357        }
358
359        // Update acquisition timestamp when we scanned for gaps
360        if !acquisition_suggestions.is_empty() || decided.iter().any(|a| matches!(a, AgentAction::Suggest { .. })) {
361            self.state.last_acquisition_at = Some(now_str.clone());
362        }
363
364        // ------------------------------------------------------------------ //
365        // Update state
366        // ------------------------------------------------------------------ //
367
368        self.state.cycles += 1;
369        self.state.total_actions += decided.len() as u64;
370
371        let duration_ms = cycle_start.elapsed().as_millis() as u64;
372
373        Ok(CycleResult {
374            actions: decided,
375            cycle_number: self.state.cycles,
376            duration_ms,
377        })
378    }
379
380    // -------------------------------------------------------------------------
381    // Garden scheduling
382    // -------------------------------------------------------------------------
383
384    /// Returns `true` if enough time has passed since the last garden run to
385    /// justify running another one.
386    ///
387    /// - If the garden has never run, returns `true`.
388    /// - Otherwise, returns `true` only if `garden_interval_secs` seconds have
389    ///   elapsed since `state.last_garden_at`.
390    pub fn should_garden(&self) -> bool {
391        match &self.state.last_garden_at {
392            None => true,
393            Some(last_str) => {
394                if let Ok(last_dt) = chrono::DateTime::parse_from_rfc3339(last_str) {
395                    let now = Utc::now();
396                    let elapsed = (now.timestamp() - last_dt.timestamp()).max(0) as u64;
397                    elapsed >= self.config.garden_interval_secs
398                } else {
399                    // Unparseable timestamp — treat as "never gardened"
400                    true
401                }
402            }
403        }
404    }
405
406    // -------------------------------------------------------------------------
407    // Metrics & introspection
408    // -------------------------------------------------------------------------
409
410    /// Compute current performance metrics from accumulated state.
411    pub fn metrics(&self) -> AgentMetrics {
412        let uptime_secs = self
413            .started_at
414            .map(|t| t.elapsed().as_secs())
415            .unwrap_or(0);
416
417        AgentMetrics {
418            cycles: self.state.cycles,
419            total_actions: self.state.total_actions,
420            memories_pruned: self.memories_pruned,
421            memories_merged: self.memories_merged,
422            memories_archived: self.memories_archived,
423            suggestions_made: self.suggestions_made,
424            gardens_run: self.gardens_run,
425            uptime_secs,
426        }
427    }
428
429    /// Replace the current configuration.
430    ///
431    /// Changes take effect on the next call to `tick()`.
432    pub fn configure(&mut self, new_config: AgentConfig) {
433        self.config = new_config;
434    }
435
436    /// Return a snapshot of the current agent state.
437    pub fn status(&self) -> AgentState {
438        self.state.clone()
439    }
440}
441
442// =============================================================================
443// Tests
444// =============================================================================
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use rusqlite::Connection;
450
451    // -------------------------------------------------------------------------
452    // Test DB helpers
453    // -------------------------------------------------------------------------
454
455    /// Minimal schema for agent loop tests.
456    fn setup_conn() -> Connection {
457        let conn = Connection::open_in_memory().expect("in-memory db");
458        conn.execute_batch(
459            "CREATE TABLE IF NOT EXISTS memories (
460                id                INTEGER PRIMARY KEY AUTOINCREMENT,
461                content           TEXT    NOT NULL,
462                memory_type       TEXT    NOT NULL DEFAULT 'note',
463                workspace         TEXT    NOT NULL DEFAULT 'default',
464                importance        REAL    NOT NULL DEFAULT 0.5,
465                updated_at        TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
466                last_accessed_at  TEXT,
467                created_at        TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
468            );
469            CREATE TABLE IF NOT EXISTS tags (
470                id        INTEGER PRIMARY KEY AUTOINCREMENT,
471                memory_id INTEGER NOT NULL,
472                tag       TEXT    NOT NULL,
473                FOREIGN KEY(memory_id) REFERENCES memories(id)
474            );",
475        )
476        .expect("create schema");
477        conn
478    }
479
480    fn insert_memory(
481        conn: &Connection,
482        content: &str,
483        importance: f32,
484        updated_at: &str,
485        workspace: &str,
486    ) -> i64 {
487        conn.execute(
488            "INSERT INTO memories (content, importance, updated_at, created_at, workspace)
489             VALUES (?1, ?2, ?3, ?3, ?4)",
490            rusqlite::params![content, importance as f64, updated_at, workspace],
491        )
492        .expect("insert");
493        conn.last_insert_rowid()
494    }
495
496    // -------------------------------------------------------------------------
497    // Test 1 — new agent is not running
498    // -------------------------------------------------------------------------
499    #[test]
500    fn test_new_agent_not_running() {
501        let agent = MemoryAgent::new(AgentConfig::default());
502        assert!(!agent.is_running(), "new agent should not be running");
503        assert_eq!(agent.state.cycles, 0);
504        assert_eq!(agent.state.total_actions, 0);
505        assert!(agent.state.last_garden_at.is_none());
506        assert!(agent.state.last_acquisition_at.is_none());
507    }
508
509    // -------------------------------------------------------------------------
510    // Test 2 — start/stop lifecycle
511    // -------------------------------------------------------------------------
512    #[test]
513    fn test_start_stop_lifecycle() {
514        let mut agent = MemoryAgent::new(AgentConfig::default());
515
516        agent.start();
517        assert!(agent.is_running(), "agent should be running after start()");
518
519        agent.stop();
520        assert!(!agent.is_running(), "agent should not be running after stop()");
521
522        // Uptime should be non-zero after start (even if tiny)
523        let m = agent.metrics();
524        // started_at was set during start(), so uptime_secs reflects elapsed
525        // time. It may be 0 for very fast tests — just assert it's accessible.
526        assert!(m.uptime_secs < u64::MAX, "uptime should be a valid value");
527    }
528
529    // -------------------------------------------------------------------------
530    // Test 3 — tick on empty workspace produces no actions
531    // -------------------------------------------------------------------------
532    #[test]
533    fn test_tick_empty_workspace() {
534        let conn = setup_conn();
535        let mut agent = MemoryAgent::new(AgentConfig {
536            workspace: "empty".to_string(),
537            ..AgentConfig::default()
538        });
539
540        let result = agent.tick(&conn).expect("tick");
541
542        // Empty workspace: gardener has no candidates, gap detector finds nothing
543        assert_eq!(
544            result.cycle_number, 1,
545            "cycle_number should be 1 after first tick"
546        );
547        // Actions may include a Garden action (should_garden returns true first time)
548        // but no Prune/Merge/Archive since workspace is empty
549        for action in &result.actions {
550            assert!(
551                !matches!(action, AgentAction::Prune { .. }),
552                "should not prune in empty workspace"
553            );
554            assert!(
555                !matches!(action, AgentAction::Merge { .. }),
556                "should not merge in empty workspace"
557            );
558            assert!(
559                !matches!(action, AgentAction::Archive { .. }),
560                "should not archive in empty workspace"
561            );
562        }
563    }
564
565    // -------------------------------------------------------------------------
566    // Test 4 — tick increments cycles and total_actions
567    // -------------------------------------------------------------------------
568    #[test]
569    fn test_tick_increments_state() {
570        let conn = setup_conn();
571        let mut agent = MemoryAgent::new(AgentConfig::default());
572
573        agent.tick(&conn).expect("tick 1");
574        assert_eq!(agent.state.cycles, 1);
575
576        agent.tick(&conn).expect("tick 2");
577        assert_eq!(agent.state.cycles, 2);
578
579        // total_actions counter is updated on each tick; value depends on actions taken.
580        let _ = agent.state.total_actions; // access to suppress dead-code hints
581    }
582
583    // -------------------------------------------------------------------------
584    // Test 5 — tick produces prune/archive actions for stale low-importance memories
585    // -------------------------------------------------------------------------
586    #[test]
587    fn test_tick_produces_prune_actions() {
588        let conn = setup_conn();
589
590        // Insert very old, very low importance memory → gardener will want to prune
591        insert_memory(
592            &conn,
593            "completely stale irrelevant note",
594            0.01,
595            "2000-01-01T00:00:00Z",
596            "default",
597        );
598
599        let mut agent = MemoryAgent::new(AgentConfig::default());
600        let result = agent.tick(&conn).expect("tick");
601
602        let has_prune = result
603            .actions
604            .iter()
605            .any(|a| matches!(a, AgentAction::Prune { .. }));
606
607        // The gardener (dry-run, prune_threshold=0.2) should flag this memory
608        assert!(has_prune, "expected at least one Prune action for stale memory");
609    }
610
611    // -------------------------------------------------------------------------
612    // Test 6 — metrics track cycles correctly
613    // -------------------------------------------------------------------------
614    #[test]
615    fn test_metrics_track_cycles() {
616        let conn = setup_conn();
617        let mut agent = MemoryAgent::new(AgentConfig::default());
618
619        agent.start();
620
621        for _ in 0..3 {
622            agent.tick(&conn).expect("tick");
623        }
624
625        let m = agent.metrics();
626        assert_eq!(m.cycles, 3, "metrics.cycles should equal number of ticks");
627        assert_eq!(m.total_actions, agent.state.total_actions);
628        assert!(m.uptime_secs < 60, "uptime should be seconds, not huge");
629    }
630
631    // -------------------------------------------------------------------------
632    // Test 7 — configure updates config
633    // -------------------------------------------------------------------------
634    #[test]
635    fn test_configure_updates_config() {
636        let mut agent = MemoryAgent::new(AgentConfig::default());
637
638        assert_eq!(agent.config.check_interval_secs, 300);
639        assert_eq!(agent.config.max_actions_per_cycle, 10);
640
641        agent.configure(AgentConfig {
642            check_interval_secs: 60,
643            garden_interval_secs: 600,
644            max_actions_per_cycle: 5,
645            workspace: "my-ws".to_string(),
646        });
647
648        assert_eq!(agent.config.check_interval_secs, 60);
649        assert_eq!(agent.config.garden_interval_secs, 600);
650        assert_eq!(agent.config.max_actions_per_cycle, 5);
651        assert_eq!(agent.config.workspace, "my-ws");
652    }
653
654    // -------------------------------------------------------------------------
655    // Test 8 — should_garden timing
656    // -------------------------------------------------------------------------
657    #[test]
658    fn test_should_garden_timing() {
659        let mut agent = MemoryAgent::new(AgentConfig {
660            garden_interval_secs: 3600,
661            ..AgentConfig::default()
662        });
663
664        // Never gardened → should garden
665        assert!(agent.should_garden(), "should garden when no previous run");
666
667        // Set last_garden_at to just now → should NOT garden yet
668        let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
669        agent.state.last_garden_at = Some(now);
670        assert!(
671            !agent.should_garden(),
672            "should NOT garden immediately after a run"
673        );
674
675        // Set last_garden_at to the past beyond the interval → should garden
676        let past = "2000-01-01T00:00:00Z".to_string();
677        agent.state.last_garden_at = Some(past);
678        assert!(
679            agent.should_garden(),
680            "should garden when interval has elapsed"
681        );
682    }
683
684    // -------------------------------------------------------------------------
685    // Test 9 — max_actions_per_cycle is respected
686    // -------------------------------------------------------------------------
687    #[test]
688    fn test_max_actions_per_cycle_capped() {
689        let conn = setup_conn();
690
691        // Insert many stale memories so gardener has lots of prune candidates
692        for i in 0..20 {
693            insert_memory(
694                &conn,
695                &format!("stale note number {}", i),
696                0.01,
697                "2000-01-01T00:00:00Z",
698                "default",
699            );
700        }
701
702        let mut agent = MemoryAgent::new(AgentConfig {
703            max_actions_per_cycle: 3,
704            ..AgentConfig::default()
705        });
706
707        let result = agent.tick(&conn).expect("tick");
708        assert!(
709            result.actions.len() <= 3,
710            "actions should be capped at max_actions_per_cycle=3, got {}",
711            result.actions.len()
712        );
713    }
714
715    // -------------------------------------------------------------------------
716    // Test 10 — status() returns clone of current state
717    // -------------------------------------------------------------------------
718    #[test]
719    fn test_status_returns_state_snapshot() {
720        let mut agent = MemoryAgent::new(AgentConfig::default());
721        agent.start();
722
723        let status = agent.status();
724        assert!(status.running);
725        assert_eq!(status.cycles, agent.state.cycles);
726        assert_eq!(status.total_actions, agent.state.total_actions);
727    }
728
729    // -------------------------------------------------------------------------
730    // Test 11 — garden action appears in first tick (should_garden = true)
731    // -------------------------------------------------------------------------
732    #[test]
733    fn test_garden_action_in_first_tick() {
734        let conn = setup_conn();
735        let mut agent = MemoryAgent::new(AgentConfig::default());
736
737        // On first tick, should_garden() returns true → a Garden action is added
738        let result = agent.tick(&conn).expect("tick");
739
740        let has_garden = result
741            .actions
742            .iter()
743            .any(|a| matches!(a, AgentAction::Garden { .. }));
744
745        assert!(has_garden, "first tick should include a Garden action");
746        assert!(
747            agent.state.last_garden_at.is_some(),
748            "last_garden_at should be set after a Garden action"
749        );
750    }
751
752    // -------------------------------------------------------------------------
753    // Test 12 — uptime is 0 when never started
754    // -------------------------------------------------------------------------
755    #[test]
756    fn test_uptime_zero_when_not_started() {
757        let agent = MemoryAgent::new(AgentConfig::default());
758        let m = agent.metrics();
759        assert_eq!(m.uptime_secs, 0, "uptime should be 0 before start()");
760    }
761}