Skip to main content

hyper_agent_ai/
agent_history.rs

1//! Persistent storage for agent loop iteration history.
2//!
3//! Stores `AgentLoopLog` entries in a local SQLite database so that
4//! past iterations can be queried via the daemon API or CLI.
5
6use rusqlite::{params, Connection};
7
8use crate::agent_loop::AgentLoopLog;
9
10/// Error type for agent history operations.
11#[derive(Debug, thiserror::Error)]
12pub enum HistoryError {
13    #[error("Database error: {0}")]
14    Db(#[from] rusqlite::Error),
15}
16
17/// SQLite-backed store for agent loop iteration history.
18pub struct AgentHistoryStore {
19    conn: Connection,
20}
21
22impl AgentHistoryStore {
23    /// Open (or create) the history database at the given path.
24    ///
25    /// Creates the `agent_loop_history` table and index if they do not exist.
26    pub fn new(db_path: &str) -> Result<Self, HistoryError> {
27        let conn = Connection::open(db_path)?;
28
29        conn.execute_batch(
30            "CREATE TABLE IF NOT EXISTS agent_loop_history (
31                id              INTEGER PRIMARY KEY AUTOINCREMENT,
32                agent_id        TEXT    NOT NULL,
33                iteration       INTEGER NOT NULL,
34                timestamp       TEXT    NOT NULL,
35                phase           TEXT    NOT NULL,
36                success         INTEGER NOT NULL,
37                message         TEXT    NOT NULL,
38                decision_summary TEXT,
39                tool_turn_count INTEGER NOT NULL,
40                risk_blocked    INTEGER NOT NULL,
41                order_executed  INTEGER NOT NULL
42            );
43            CREATE INDEX IF NOT EXISTS idx_agent_history_agent_id
44                ON agent_loop_history(agent_id, id DESC);",
45        )?;
46
47        Ok(Self { conn })
48    }
49
50    /// Insert a single iteration log entry.
51    pub fn insert(&self, log: &AgentLoopLog) -> Result<(), HistoryError> {
52        self.conn.execute(
53            "INSERT INTO agent_loop_history
54                (agent_id, iteration, timestamp, phase, success, message,
55                 decision_summary, tool_turn_count, risk_blocked, order_executed)
56             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
57            params![
58                log.agent_id,
59                log.iteration,
60                log.timestamp,
61                log.phase,
62                log.success as i32,
63                log.message,
64                log.decision_summary,
65                log.tool_turn_count,
66                log.risk_blocked as i32,
67                log.order_executed as i32,
68            ],
69        )?;
70        Ok(())
71    }
72
73    /// List the most recent iterations for an agent, ordered newest-first.
74    pub fn list(&self, agent_id: &str, limit: usize) -> Result<Vec<AgentLoopLog>, HistoryError> {
75        let mut stmt = self.conn.prepare(
76            "SELECT agent_id, iteration, timestamp, phase, success, message,
77                    decision_summary, tool_turn_count, risk_blocked, order_executed
78             FROM agent_loop_history
79             WHERE agent_id = ?1
80             ORDER BY id DESC
81             LIMIT ?2",
82        )?;
83
84        let rows = stmt.query_map(params![agent_id, limit as i64], |row| {
85            Ok(AgentLoopLog {
86                agent_id: row.get(0)?,
87                iteration: row.get(1)?,
88                timestamp: row.get(2)?,
89                phase: row.get(3)?,
90                success: row.get::<_, i32>(4)? != 0,
91                message: row.get(5)?,
92                decision_summary: row.get(6)?,
93                tool_turn_count: row.get::<_, i32>(7)? as u32,
94                risk_blocked: row.get::<_, i32>(8)? != 0,
95                order_executed: row.get::<_, i32>(9)? != 0,
96            })
97        })?;
98
99        let mut logs = Vec::new();
100        for row in rows {
101            logs.push(row?);
102        }
103        Ok(logs)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn sample_log(agent_id: &str, iteration: u64) -> AgentLoopLog {
112        AgentLoopLog {
113            agent_id: agent_id.to_string(),
114            iteration,
115            timestamp: "2026-03-22T10:00:00Z".to_string(),
116            phase: "complete".to_string(),
117            success: true,
118            message: "No action".to_string(),
119            decision_summary: Some("Hold".to_string()),
120            tool_turn_count: 1,
121            risk_blocked: false,
122            order_executed: false,
123        }
124    }
125
126    #[test]
127    fn insert_and_list() {
128        let store = AgentHistoryStore::new(":memory:").unwrap();
129        store.insert(&sample_log("agent-1", 0)).unwrap();
130        store.insert(&sample_log("agent-1", 1)).unwrap();
131        store.insert(&sample_log("agent-2", 0)).unwrap();
132
133        let logs = store.list("agent-1", 10).unwrap();
134        assert_eq!(logs.len(), 2);
135        // Newest first
136        assert_eq!(logs[0].iteration, 1);
137        assert_eq!(logs[1].iteration, 0);
138
139        let logs2 = store.list("agent-2", 10).unwrap();
140        assert_eq!(logs2.len(), 1);
141    }
142
143    #[test]
144    fn list_respects_limit() {
145        let store = AgentHistoryStore::new(":memory:").unwrap();
146        for i in 0..10 {
147            store.insert(&sample_log("agent-1", i)).unwrap();
148        }
149        let logs = store.list("agent-1", 3).unwrap();
150        assert_eq!(logs.len(), 3);
151        assert_eq!(logs[0].iteration, 9);
152    }
153
154    #[test]
155    fn empty_list() {
156        let store = AgentHistoryStore::new(":memory:").unwrap();
157        let logs = store.list("nonexistent", 10).unwrap();
158        assert!(logs.is_empty());
159    }
160}