Skip to main content

oxios_kernel/kernel_handle/
agent_api.rs

1//! Agent API — agent lifecycle, budget, memory, history log.
2
3use crate::agent_log_db::{AgentListFilter, AgentStats, QueryResult};
4use crate::budget::{BudgetExceeded, BudgetInfo, BudgetLimit, BudgetManager};
5use crate::event_bus::{EventBus, KernelEvent};
6use crate::memory::{HnswMemoryIndex, SemanticHit};
7use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
8use crate::state_store::StateStore;
9use crate::supervisor::Supervisor;
10use crate::types::{AgentId, AgentInfo};
11use std::sync::Arc;
12
13/// Agent management system calls.
14pub struct AgentApi {
15    pub(crate) supervisor: Arc<dyn Supervisor>,
16    pub(crate) budget_manager: Arc<BudgetManager>,
17    pub(crate) memory_manager: Arc<MemoryManager>,
18    /// HNSW index for semantic search (optional, initialized on demand).
19    pub(crate) hnsw_index: Option<Arc<HnswMemoryIndex>>,
20    /// Event bus for publishing agent-related events.
21    pub(crate) event_bus: Option<EventBus>,
22    /// State store for filesystem agent persistence.
23    pub(crate) state_store: Option<Arc<StateStore>>,
24    /// SQLite-backed agent history query index.
25    #[cfg(feature = "sqlite-memory")]
26    pub(crate) agent_log_db: Option<Arc<crate::agent_log_db::AgentLogDb>>,
27}
28
29impl AgentApi {
30    /// Create a new AgentApi.
31    pub fn new(
32        supervisor: Arc<dyn Supervisor>,
33        budget_manager: Arc<BudgetManager>,
34        memory_manager: Arc<MemoryManager>,
35        event_bus: Option<EventBus>,
36    ) -> Self {
37        Self {
38            supervisor,
39            budget_manager,
40            memory_manager,
41            hnsw_index: None,
42            event_bus,
43            state_store: None,
44            #[cfg(feature = "sqlite-memory")]
45            agent_log_db: None,
46        }
47    }
48
49    /// Attach a state store for agent history persistence.
50    pub fn set_state_store(&mut self, store: Arc<StateStore>) {
51        self.state_store = Some(store);
52    }
53
54    /// Attach an SQLite-backed agent log database.
55    #[cfg(feature = "sqlite-memory")]
56    pub fn set_agent_log_db(&mut self, db: Arc<crate::agent_log_db::AgentLogDb>) {
57        self.agent_log_db = Some(db);
58    }
59
60    /// Attach an HNSW index for semantic search.
61    pub fn set_hnsw_index(&mut self, index: Arc<HnswMemoryIndex>) {
62        self.hnsw_index = Some(index);
63    }
64    /// Check whether an HNSW index is attached for fast semantic search.
65    pub fn has_hnsw_index(&self) -> bool {
66        self.hnsw_index.is_some()
67    }
68
69    /// Publish a kernel event if the event bus is available.
70    fn publish(&self, event: KernelEvent) {
71        if let Some(ref eb) = self.event_bus {
72            let _ = eb.publish(event);
73        }
74    }
75    /// List running agents (in-memory only).
76    pub async fn list(&self) -> anyhow::Result<Vec<AgentInfo>> {
77        self.supervisor
78            .list()
79            .await
80            .map_err(|e| anyhow::anyhow!("supervisor: {e}"))
81    }
82
83    /// Kill a running agent.
84    pub async fn kill(&self, agent_id: &str) -> anyhow::Result<()> {
85        let id = uuid::Uuid::parse_str(agent_id)
86            .map_err(|e| anyhow::anyhow!("invalid agent id: {e}"))?;
87        self.supervisor
88            .kill(id)
89            .await
90            .map_err(|e| anyhow::anyhow!("supervisor: {e}"))
91    }
92
93    /// Check budget for an agent.
94    pub fn check_budget(&self, agent_id: &AgentId) -> BudgetInfo {
95        self.budget_manager.remaining(agent_id)
96    }
97
98    /// Set budget for an agent.
99    pub fn set_budget(&self, limit: BudgetLimit) {
100        self.budget_manager.set_budget(limit);
101    }
102
103    /// Remove budget for an agent.
104    pub fn remove_budget(&self, agent_id: &AgentId) {
105        self.budget_manager.remove_budget(agent_id);
106    }
107
108    /// Reserve tokens for an agent.
109    pub fn reserve_budget(&self, agent_id: &AgentId, tokens: u64) -> Result<(), BudgetExceeded> {
110        self.budget_manager.reserve(agent_id, tokens)
111    }
112
113    /// Reset budget window for an agent.
114    pub fn reset_budget(&self, agent_id: &AgentId) {
115        self.budget_manager.reset_window(agent_id);
116    }
117
118    /// Get full budget info (limits + usage) for an agent.
119    pub fn full_budget_info(&self, agent_id: &AgentId) -> Option<crate::budget::FullBudgetInfo> {
120        self.budget_manager.full_info(agent_id)
121    }
122
123    /// Get full budget info for all agents with configured budgets.
124    pub fn all_budget_info(&self) -> Vec<crate::budget::FullBudgetInfo> {
125        self.budget_manager.all_full_info()
126    }
127
128    /// Get memory stats.
129    pub async fn memory_stats(&self) -> (usize, usize) {
130        (
131            self.memory_manager.vector_index_size(),
132            self.memory_manager.total_entries().await,
133        )
134    }
135
136    /// Store a memory entry.
137    pub async fn remember(&self, entry: MemoryEntry) -> anyhow::Result<String> {
138        let id = self.memory_manager.remember(entry.clone()).await?;
139
140        // Publish MemoryStored event
141        self.publish(KernelEvent::MemoryStored {
142            id: id.clone(),
143            memory_type: entry.memory_type.label().to_string(),
144            source: entry.source.clone(),
145        });
146
147        Ok(id)
148    }
149
150    /// List memories across all types, most-recent-first, capped at `limit`.
151    pub async fn list_all_memories(&self, limit: usize) -> Vec<MemoryEntry> {
152        let mut all = Vec::new();
153        for mt in MemoryType::all() {
154            if let Ok(entries) = self.memory_manager.list(*mt, limit).await {
155                all.extend(entries);
156            }
157        }
158        all.sort_by_key(|e| std::cmp::Reverse(e.created_at));
159        all.into_iter().take(limit).collect()
160    }
161
162    /// Get a memory entry by ID, searching all types.
163    pub async fn get_memory(&self, id: &str) -> Option<MemoryEntry> {
164        self.memory_manager.get_by_id(id).await.ok().flatten()
165    }
166
167    /// Pin or unpin a memory entry. Returns `false` if no entry has this ID.
168    pub async fn set_memory_pinned(&self, id: &str, pinned: bool) -> bool {
169        if self
170            .memory_manager
171            .get_by_id(id)
172            .await
173            .ok()
174            .flatten()
175            .is_none()
176        {
177            return false;
178        }
179        let res = if pinned {
180            self.memory_manager.pin(id).await
181        } else {
182            self.memory_manager.unpin(id).await
183        };
184        res.is_ok()
185    }
186
187    /// Delete a memory entry by ID. Returns `false` if not found.
188    pub async fn forget_memory(&self, id: &str) -> bool {
189        // `forget` is keyed by (id, type); resolve the type via a lookup first.
190        let memory_type = match self.memory_manager.get_by_id(id).await {
191            Ok(Some(entry)) => entry.memory_type,
192            _ => return false,
193        };
194        self.memory_manager
195            .forget(id, memory_type)
196            .await
197            .unwrap_or(false)
198    }
199
200    /// Search memory entries.
201    pub async fn search_memory(
202        &self,
203        query: &str,
204        memory_type: Option<MemoryType>,
205        limit: usize,
206    ) -> anyhow::Result<Vec<MemoryEntry>> {
207        self.memory_manager.search(query, memory_type, limit).await
208    }
209
210    /// Semantic search using HNSW index.
211    ///
212    /// Falls back to regular search if HNSW index is not available.
213    pub async fn semantic_search_memory(
214        &self,
215        query: &str,
216        memory_type: Option<MemoryType>,
217        limit: usize,
218    ) -> anyhow::Result<Vec<SemanticHit>> {
219        if let Some(ref hnsw) = self.hnsw_index {
220            self.memory_manager
221                .semantic_search(query, memory_type, limit, hnsw)
222                .await
223        } else {
224            // Fallback to regular search, wrap results
225            let entries = self.search_memory(query, memory_type, limit).await?;
226            Ok(entries
227                .into_iter()
228                .map(|entry| SemanticHit {
229                    entry,
230                    distance: 0.0,
231                    similarity: 0.0,
232                })
233                .collect())
234        }
235    }
236
237    /// Memory manager reference.
238    pub fn memory_manager(&self) -> &Arc<MemoryManager> {
239        &self.memory_manager
240    }
241
242    // ── Agent History Log ─────────────────────────────────────────
243
244    /// Query agent history (in-memory + SQLite) with filters.
245    ///
246    /// Merges running agents from supervisor with persisted agents
247    /// from the SQLite query index. Running agents are prepended.
248    pub async fn query(&self, filter: &AgentListFilter) -> anyhow::Result<QueryResult> {
249        // Get running agents from supervisor
250        let running = self.supervisor.list().await.unwrap_or_default();
251
252        // Query SQLite for historical agents
253        #[cfg(feature = "sqlite-memory")]
254        if let Some(ref db) = self.agent_log_db {
255            let mut result = db.query(filter).map_err(|e| anyhow::anyhow!("{e}"))?;
256
257            // Only prepend running agents on the first page — otherwise we'd
258            // inject them into every requested page and break pagination.
259            if filter.page == 1 {
260                // Dedup against the SQLite items so a running agent that is
261                // also already persisted isn't shown twice.
262                let existing_ids: std::collections::HashSet<_> =
263                    result.items.iter().map(|a| a.id).collect();
264                let mut prepended = 0u64;
265                for agent in &running {
266                    if existing_ids.contains(&agent.id) {
267                        continue;
268                    }
269                    if filter_matches(agent, filter) {
270                        result.items.insert(0, agent.clone());
271                        prepended += 1;
272                    }
273                }
274                result.total = result.total.saturating_add(prepended);
275                // Recompute total_pages so callers see a consistent view.
276                result.total_pages = if result.total == 0 {
277                    1
278                } else {
279                    ((result.total as f64) / filter.per_page.max(1) as f64).ceil() as u32
280                };
281            }
282
283            return Ok(result);
284        }
285
286        // Fallback: filesystem-only scan
287        #[allow(unused_mut)]
288        let mut persisted: Vec<AgentInfo> = Vec::new();
289        if let Some(ref store) = self.state_store {
290            let names = store.list_category("agents").await.unwrap_or_default();
291            for name in &names {
292                if let Ok(Some(agent)) = store.load_json::<AgentInfo>("agents", name).await {
293                    persisted.push(agent);
294                }
295            }
296        }
297
298        // Merge running + persisted, dedup by id (running wins)
299        let running_ids: std::collections::HashSet<_> = running.iter().map(|a| a.id).collect();
300        persisted.retain(|a| !running_ids.contains(&a.id));
301
302        let mut all = running;
303        all.extend(persisted);
304
305        // In-memory filter/sort/paginate (basic fallback)
306        let filtered = fallback_filter(all, filter);
307        let total = filtered.len() as u64;
308        let offset = ((filter.page.max(1) - 1) * filter.per_page) as usize;
309        let limit = filter.per_page.min(200) as usize;
310        let items: Vec<AgentInfo> = filtered.into_iter().skip(offset).take(limit).collect();
311        let total_pages = if total == 0 {
312            1
313        } else {
314            ((total as f64) / filter.per_page as f64).ceil() as u32
315        };
316
317        Ok(QueryResult {
318            items,
319            total,
320            page: filter.page,
321            per_page: filter.per_page,
322            total_pages,
323            stats: crate::agent_log_db::FilteredStats::default(),
324        })
325    }
326
327    /// Get an agent by ID (from SQLite or filesystem fallback).
328    pub async fn get(&self, id: &str) -> anyhow::Result<Option<AgentInfo>> {
329        // Try SQLite first
330        #[cfg(feature = "sqlite-memory")]
331        if let Some(ref db) = self.agent_log_db
332            && let Ok(Some(agent)) = db.get(id)
333        {
334            return Ok(Some(agent));
335        }
336
337        // Fallback: filesystem JSON
338        if let Some(ref store) = self.state_store
339            && let Ok(Some(agent)) = store.load_json::<AgentInfo>("agents", id).await
340        {
341            return Ok(Some(agent));
342        }
343
344        // Fallback: in-memory
345        if let Ok(agents) = self.supervisor.list().await
346            && let Some(agent) = agents.into_iter().find(|a| a.id.to_string() == id)
347        {
348            return Ok(Some(agent));
349        }
350
351        Ok(None)
352    }
353
354    /// Global agent stats (unfiltered).
355    pub async fn stats(&self) -> anyhow::Result<AgentStats> {
356        // Try SQLite first
357        #[cfg(feature = "sqlite-memory")]
358        if let Some(ref db) = self.agent_log_db {
359            return db.stats().map_err(|e| anyhow::anyhow!("{e}"));
360        }
361
362        // Fallback: compute from in-memory + filesystem
363        let mut s = AgentStats::default();
364        let running = self.supervisor.list().await.unwrap_or_default();
365        for a in &running {
366            s.total_agents += 1;
367            match a.status {
368                crate::types::AgentStatus::Running | crate::types::AgentStatus::Starting => {
369                    s.running += 1
370                }
371                crate::types::AgentStatus::Idle
372                | crate::types::AgentStatus::Stopped
373                | crate::types::AgentStatus::Completed => s.completed += 1,
374                crate::types::AgentStatus::Failed => s.failed += 1,
375            }
376            s.total_tokens += a.tokens_input + a.tokens_output;
377            s.total_cost_usd += a.cost_usd;
378        }
379        Ok(s)
380    }
381    // ── Cost aggregation (dollar-based spend views) ─────────────────
382
383    /// Aggregate spend for a time window.
384    pub fn cost_summary(
385        &self,
386        since: Option<chrono::DateTime<chrono::Utc>>,
387    ) -> anyhow::Result<crate::agent_log_db::CostSummary> {
388        #[cfg(feature = "sqlite-memory")]
389        if let Some(ref db) = self.agent_log_db {
390            return db.cost_summary(since).map_err(|e| anyhow::anyhow!("{e}"));
391        }
392        Ok(crate::agent_log_db::CostSummary::default())
393    }
394
395    /// Per-model spend breakdown for a time window.
396    pub fn cost_by_model(
397        &self,
398        since: Option<chrono::DateTime<chrono::Utc>>,
399    ) -> anyhow::Result<Vec<crate::agent_log_db::ModelCostRow>> {
400        #[cfg(feature = "sqlite-memory")]
401        if let Some(ref db) = self.agent_log_db {
402            return db.cost_by_model(since).map_err(|e| anyhow::anyhow!("{e}"));
403        }
404        Ok(Vec::new())
405    }
406
407    /// Per-project spend breakdown for a time window.
408    pub fn cost_by_project(
409        &self,
410        since: Option<chrono::DateTime<chrono::Utc>>,
411    ) -> anyhow::Result<Vec<crate::agent_log_db::ProjectCostRow>> {
412        #[cfg(feature = "sqlite-memory")]
413        if let Some(ref db) = self.agent_log_db {
414            return db
415                .cost_by_project(since)
416                .map_err(|e| anyhow::anyhow!("{e}"));
417        }
418        Ok(Vec::new())
419    }
420
421    /// Daily spend time-series for the last `days` days.
422    pub fn cost_daily(&self, days: u32) -> anyhow::Result<Vec<crate::agent_log_db::DailyCostRow>> {
423        #[cfg(feature = "sqlite-memory")]
424        if let Some(ref db) = self.agent_log_db {
425            return db.cost_daily(days).map_err(|e| anyhow::anyhow!("{e}"));
426        }
427        Ok(Vec::new())
428    }
429
430    /// Rebuild SQLite agent log index from filesystem JSON.
431    #[cfg(feature = "sqlite-memory")]
432    pub async fn reindex(&self) -> anyhow::Result<crate::agent_log_db::RebuildReport> {
433        match (self.agent_log_db.as_ref(), self.state_store.as_ref()) {
434            (Some(db), Some(store)) => db
435                .reindex_all(store)
436                .await
437                .map_err(|e| anyhow::anyhow!("{e}")),
438            _ => anyhow::bail!("Agent log DB not initialized"),
439        }
440    }
441
442    /// Rebuild the HNSW index from all stored memories.
443    pub async fn rebuild_hnsw_index(&self) -> anyhow::Result<usize> {
444        if let Some(ref hnsw) = self.hnsw_index {
445            self.memory_manager.rebuild_hnsw_index(hnsw).await
446        } else {
447            Err(anyhow::anyhow!("HNSW index not initialized"))
448        }
449    }
450}
451
452/// Check if an agent matches the filter (used for prepending running agents).
453fn filter_matches(agent: &AgentInfo, filter: &AgentListFilter) -> bool {
454    // Status filter
455    if let Some(status) = filter.status {
456        let status_str = agent.status.to_string();
457        if status_str != status.as_sql()
458            && !(status_str == "idle" && status.as_sql() == "completed")
459            && !(status_str == "idle" && status.as_sql() == "running")
460        {
461            return false;
462        }
463    }
464
465    // Date range
466    if let Some(from) = filter.date_from
467        && agent.created_at < from
468    {
469        return false;
470    }
471    if let Some(to) = filter.date_to
472        && agent.created_at > to
473    {
474        return false;
475    }
476
477    // Session / project
478    if let Some(ref sid) = filter.session_id
479        && agent.session_id.as_deref() != Some(sid.as_str())
480    {
481        return false;
482    }
483    if let Some(ref pid) = filter.project_id
484        && agent.project_id.map(|p| p.to_string()).as_deref() != Some(pid.as_str())
485    {
486        return false;
487    }
488
489    // Model filter (substring)
490    if let Some(ref model) = filter.model_id
491        && !agent.model_id.contains(model)
492    {
493        return false;
494    }
495
496    // Text search (name + error only for in-memory agents — no tool_calls scan)
497    if let Some(ref q) = filter.q {
498        let q_lower = q.to_lowercase();
499        let name_match = agent.name.to_lowercase().contains(&q_lower);
500        let error_match = agent
501            .error
502            .as_deref()
503            .is_some_and(|e| e.to_lowercase().contains(&q_lower));
504        if !name_match && !error_match {
505            return false;
506        }
507    }
508
509    // Error filter
510    if let Some(has_err) = filter.has_error {
511        let agent_has_err = agent.error.as_deref().is_some_and(|e| !e.is_empty());
512        if has_err != agent_has_err {
513            return false;
514        }
515    }
516
517    // Budget ranges
518    if let Some(min) = filter.cost_min
519        && agent.cost_usd < min
520    {
521        return false;
522    }
523    if let Some(max) = filter.cost_max
524        && agent.cost_usd > max
525    {
526        return false;
527    }
528
529    true
530}
531
532/// Fallback in-memory filtering (used when SQLite is not available).
533fn fallback_filter(mut agents: Vec<AgentInfo>, filter: &AgentListFilter) -> Vec<AgentInfo> {
534    // Sort
535    match filter.sort_by {
536        crate::agent_log_db::SortBy::CreatedAt => {
537            agents.sort_by_key(|a| std::cmp::Reverse(a.created_at));
538        }
539        crate::agent_log_db::SortBy::Cost => {
540            agents.sort_by(|a, b| {
541                b.cost_usd
542                    .partial_cmp(&a.cost_usd)
543                    .unwrap_or(std::cmp::Ordering::Equal)
544            });
545        }
546        crate::agent_log_db::SortBy::Duration => {
547            let dur = |a: &AgentInfo| -> i64 {
548                match (a.started_at, a.completed_at) {
549                    (Some(s), Some(e)) => (e - s).num_seconds(),
550                    _ => 0,
551                }
552            };
553            agents.sort_by_key(|a| std::cmp::Reverse(dur(a)));
554        }
555        crate::agent_log_db::SortBy::Tokens => {
556            agents.sort_by_key(|a| std::cmp::Reverse(a.tokens_input + a.tokens_output));
557        }
558        crate::agent_log_db::SortBy::Name => {
559            agents.sort_by(|a, b| a.name.cmp(&b.name));
560        }
561    }
562
563    agents
564}