Skip to main content

aegis_memory/
sidecar.rs

1use crate::entry::MemoryEntry;
2use aegis_provider::Provider;
3use aegis_types::message::Message;
4use std::time::Duration;
5
6/// Result of relevance scoring for a single memory entry.
7#[derive(Debug, Clone)]
8pub struct RelevanceResult {
9    pub entry_id: String,
10    pub score: f32,
11    pub relevant: bool,
12}
13
14/// Configuration for the memory sidecar relevance check.
15#[derive(Debug, Clone)]
16pub struct SidecarConfig {
17    pub enabled: bool,
18    /// Minimum confidence to pass the entry through to the LLM for relevance check.
19    /// Entries below this are pre-filtered.
20    pub min_confidence: f32,
21    /// Minimum relevance score from the LLM to keep a memory entry.
22    pub min_score: f32,
23    /// Timeout for the LLM relevance check.
24    pub timeout_ms: u64,
25    /// If true, reinforce entries that pass relevance check (positive) and
26    /// decay entries that fail (negative).
27    pub reinforce_on_check: bool,
28    /// Reinforcement score for passing entries.
29    pub reinforce_pass_score: f32,
30    /// Reinforcement score for failing entries.
31    pub reinforce_fail_score: f32,
32}
33
34impl Default for SidecarConfig {
35    fn default() -> Self {
36        Self {
37            enabled: true,
38            min_confidence: 0.3,
39            min_score: 0.5,
40            timeout_ms: 500,
41            reinforce_on_check: true,
42            reinforce_pass_score: 0.05,
43            reinforce_fail_score: -0.02,
44        }
45    }
46}
47
48/// Spawns a relevance check against the given candidates using an LLM provider.
49/// Returns only the candidates deemed relevant, with feedback applied.
50///
51/// Processing pipeline:
52/// 1. Pre-filter: remove entries below min_confidence
53/// 2. LLM relevance check (with timeout + graceful degradation)
54/// 3. Feedback: reinforce passing entries, decay failing entries
55/// 4. Return relevant entries sorted by relevance score
56pub async fn spawn_relevance_check(
57    query: &str,
58    mut candidates: Vec<MemoryEntry>,
59    provider: &dyn Provider,
60    config: &SidecarConfig,
61) -> Vec<MemoryEntry> {
62    if candidates.is_empty() || !config.enabled {
63        return candidates;
64    }
65
66    // Step 1: Pre-filter by confidence
67    let before_count = candidates.len();
68    candidates.retain(|e| e.effective_confidence() >= config.min_confidence);
69    let filtered_count = before_count - candidates.len();
70    if filtered_count > 0 {
71        tracing::debug!("sidecar: pre-filtered {filtered_count} entries below confidence {}", config.min_confidence);
72    }
73
74    if candidates.is_empty() {
75        return candidates;
76    }
77
78    // Step 2: LLM relevance check
79    let listing: String = candidates
80        .iter()
81        .enumerate()
82        .map(|(i, e)| {
83            format!(
84                "{}. [conf={:.2} trust={:?}] {}",
85                i,
86                e.effective_confidence(),
87                e.trust,
88                e.content
89            )
90        })
91        .collect::<Vec<_>>()
92        .join("\n");
93
94    let prompt = format!(
95        "Given the user query: \"{query}\"\n\n\
96         Here are candidate memories:\n{listing}\n\n\
97         Return a JSON array of the indices (0-based) that are relevant to the query. \
98         Only output the JSON array, nothing else."
99    );
100
101    let messages = vec![Message::user(prompt)];
102
103    let timeout = Duration::from_millis(config.timeout_ms);
104    let result = tokio::time::timeout(timeout, provider.chat(&messages, None)).await;
105
106    let response = match result {
107        Ok(Ok(resp)) => resp,
108        _ => {
109            tracing::warn!("sidecar: relevance check failed or timed out, returning all candidates");
110            return candidates;
111        }
112    };
113
114    let text = response.message.text();
115    let indices: Vec<usize> = match serde_json::from_str(&text) {
116        Ok(v) => v,
117        Err(_) => {
118            // Try to extract a JSON array from the response text
119            let start = text.find('[');
120            let end = text.rfind(']');
121            match (start, end) {
122                (Some(s), Some(e)) if s < e => match serde_json::from_str(&text[s..=e]) {
123                    Ok(v) => v,
124                    Err(_) => return candidates,
125                },
126                _ => return candidates,
127            }
128        }
129    };
130
131    // Step 3: Apply feedback (reinforce/decay) if enabled
132    if config.reinforce_on_check {
133        for (i, entry) in candidates.iter_mut().enumerate() {
134            if indices.contains(&i) {
135                entry.reinforce(config.reinforce_pass_score, "sidecar: passed relevance check");
136            } else {
137                entry.reinforce(config.reinforce_fail_score, "sidecar: failed relevance check");
138            }
139        }
140    }
141
142    // Step 4: Filter and return
143    let len = candidates.len();
144    candidates
145        .into_iter()
146        .enumerate()
147        .filter(|(i, _)| indices.contains(i) && *i < len)
148        .map(|(_, e)| e)
149        .collect()
150}
151
152/// Batch relevance check: process multiple queries' candidates at once.
153/// Useful for multi-turn or multi-context memory injection.
154pub async fn batch_relevance_check(
155    queries: &[&str],
156    candidates_by_query: Vec<Vec<MemoryEntry>>,
157    provider: &dyn Provider,
158    config: &SidecarConfig,
159) -> Vec<Vec<MemoryEntry>> {
160    let mut results = Vec::with_capacity(queries.len());
161    for (query, candidates) in queries.iter().zip(candidates_by_query) {
162        let checked = spawn_relevance_check(query, candidates, provider, config).await;
163        results.push(checked);
164    }
165    results
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_sidecar_config_defaults() {
174        let config = SidecarConfig::default();
175        assert!(config.enabled);
176        assert_eq!(config.min_confidence, 0.3);
177        assert_eq!(config.min_score, 0.5);
178        assert!(config.reinforce_on_check);
179    }
180
181    #[test]
182    fn test_relevance_result_clone() {
183        let r = RelevanceResult {
184            entry_id: "test".to_string(),
185            score: 0.8,
186            relevant: true,
187        };
188        let r2 = r.clone();
189        assert_eq!(r2.entry_id, "test");
190        assert_eq!(r2.score, 0.8);
191    }
192}