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}