Skip to main content

aegis_memory/
context.rs

1/// ContextBuilder: layered context loading with token budget management.
2/// Priority order: Identity(0) > Bootstrap(1) > AlwaysOnSkills(2) > AvailableSkills(3) > Memory(4)
3
4#[derive(Debug, Clone)]
5/// A single memory result with its relevance score and metadata.
6pub struct MemoryResult {
7    pub content: String,
8    pub score: f32,
9    pub source: String,
10    /// Effective confidence of the memory (0.0 - 1.0).
11    pub confidence: f32,
12}
13
14#[derive(Debug, Clone)]
15/// A prioritized section of the LLM context prompt.
16pub enum ContextSection {
17    Identity {
18        content: String,
19    },
20    Bootstrap {
21        files: Vec<(String, String)>, // (name, content)
22    },
23    AlwaysOnSkills {
24        full_content: String,
25    },
26    AvailableSkills {
27        summaries: Vec<String>,
28    },
29    Memory {
30        results: Vec<MemoryResult>,
31    },
32}
33
34impl ContextSection {
35    fn priority(&self) -> u8 {
36        match self {
37            Self::Identity { .. } => 0,
38            Self::Bootstrap { .. } => 1,
39            Self::AlwaysOnSkills { .. } => 2,
40            Self::AvailableSkills { .. } => 3,
41            Self::Memory { .. } => 4,
42        }
43    }
44
45    fn render(&self) -> String {
46        match self {
47            Self::Identity { content } => format!("# Identity\n{}\n", content),
48            Self::Bootstrap { files } => {
49                let mut out = String::from("# Bootstrap\n");
50                for (name, content) in files {
51                    out.push_str(&format!("## {}\n{}\n", name, content));
52                }
53                out
54            }
55            Self::AlwaysOnSkills { full_content } => {
56                format!("# Skills (Active)\n{}\n", full_content)
57            }
58            Self::AvailableSkills { summaries } => {
59                let mut out = String::from("# Skills (Available)\n");
60                for s in summaries {
61                    out.push_str(&format!("- {}\n", s));
62                }
63                out
64            }
65            Self::Memory { results } => {
66                let mut out = String::from("# Memory\n");
67                for r in results {
68                    out.push_str(&format!(
69                        "<!-- score={:.2} confidence={:.2} src={} -->\n{}\n\n",
70                        r.score, r.confidence, r.source, r.content
71                    ));
72                }
73                out
74            }
75        }
76    }
77
78    fn render_partial(&self, token_budget: usize) -> String {
79        match self {
80            Self::Memory { results } => {
81                let mut out = String::from("# Memory\n");
82                let mut used = estimate_tokens(&out);
83                // results already sorted by score descending (caller's responsibility)
84                for r in results {
85                    let line = format!(
86                        "<!-- score={:.2} confidence={:.2} src={} -->\n{}\n\n",
87                        r.score, r.confidence, r.source, r.content
88                    );
89                    let t = estimate_tokens(&line);
90                    if used + t > token_budget {
91                        break;
92                    }
93                    out.push_str(&line);
94                    used += t;
95                }
96                out
97            }
98            other => other.render(),
99        }
100    }
101}
102
103/// Rough token estimator: ~4 chars per token
104pub fn estimate_tokens(text: &str) -> usize {
105    text.len().div_ceil(4)
106}
107
108/// Builds a context string from prioritized sections within a token budget.
109pub struct ContextBuilder {
110    pub budget: usize,
111    pub sections: Vec<ContextSection>,
112}
113
114impl ContextBuilder {
115    /// Create a new builder with the given token budget.
116    pub fn new(budget: usize) -> Self {
117        Self {
118            budget,
119            sections: Vec::new(),
120        }
121    }
122
123    /// Append a context section (builder pattern).
124    pub fn with_section(mut self, section: ContextSection) -> Self {
125        self.sections.push(section);
126        self
127    }
128
129    /// Render all sections into a single context string, respecting the token budget.
130    pub fn build(&self) -> String {
131        let mut sections = self.sections.clone();
132        sections.sort_by_key(|s| s.priority());
133
134        let mut output = String::new();
135        let mut used = 0usize;
136
137        for (i, section) in sections.iter().enumerate() {
138            let content = section.render();
139            let tokens = estimate_tokens(&content);
140            if used + tokens <= self.budget {
141                output.push_str(&content);
142                used += tokens;
143            } else {
144                // Last section: partial fill
145                let remaining = self.budget.saturating_sub(used);
146                if i == sections.len() - 1 || section.priority() == 4 {
147                    output.push_str(&section.render_partial(remaining));
148                }
149                break;
150            }
151        }
152        output
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn estimate_tokens_basic() {
162        // ~4 chars per token
163        assert_eq!(estimate_tokens("hello"), 2); // 5 chars / 4 = 2 (div_ceil)
164        assert_eq!(estimate_tokens("1234"), 1);
165        assert_eq!(estimate_tokens(""), 0);
166    }
167
168    #[test]
169    fn context_section_priority_order() {
170        let identity = ContextSection::Identity { content: "I am Aegis".into() };
171        let bootstrap = ContextSection::Bootstrap { files: vec![] };
172        let memory = ContextSection::Memory { results: vec![] };
173        assert!(identity.priority() < bootstrap.priority());
174        assert!(bootstrap.priority() < memory.priority());
175    }
176
177    #[test]
178    fn context_section_render_identity() {
179        let section = ContextSection::Identity { content: "I am Aegis".into() };
180        let rendered = section.render();
181        assert!(rendered.contains("# Identity"));
182        assert!(rendered.contains("I am Aegis"));
183    }
184
185    #[test]
186    fn context_section_render_bootstrap() {
187        let section = ContextSection::Bootstrap {
188            files: vec![("rules.md".into(), "Be helpful".into())],
189        };
190        let rendered = section.render();
191        assert!(rendered.contains("# Bootstrap"));
192        assert!(rendered.contains("rules.md"));
193        assert!(rendered.contains("Be helpful"));
194    }
195
196    #[test]
197    fn context_section_render_available_skills() {
198        let section = ContextSection::AvailableSkills {
199            summaries: vec!["skill-a".into(), "skill-b".into()],
200        };
201        let rendered = section.render();
202        assert!(rendered.contains("# Skills (Available)"));
203        assert!(rendered.contains("skill-a"));
204        assert!(rendered.contains("skill-b"));
205    }
206
207    #[test]
208    fn context_builder_respects_budget() {
209        let builder = ContextBuilder::new(10) // very small budget
210            .with_section(ContextSection::Identity { content: "I am Aegis the runtime".into() })
211            .with_section(ContextSection::Bootstrap { files: vec![("f".into(), "content".into())] });
212        let output = builder.build();
213        // With tiny budget, at most first section fits (and maybe partial last)
214        assert!(!output.is_empty());
215    }
216
217    #[test]
218    fn context_builder_priority_sorting() {
219        // Add memory first, identity second — identity should appear first in output
220        let builder = ContextBuilder::new(10000)
221            .with_section(ContextSection::Memory {
222                results: vec![MemoryResult {
223                    content: "some memory".into(),
224                    score: 0.9,
225                    source: "test".into(),
226                    confidence: 0.8,
227                }],
228            })
229            .with_section(ContextSection::Identity { content: "I am Aegis".into() });
230        let output = builder.build();
231        let identity_pos = output.find("I am Aegis").unwrap();
232        let memory_pos = output.find("some memory").unwrap();
233        assert!(identity_pos < memory_pos, "identity should come before memory");
234    }
235
236    #[test]
237    fn context_builder_empty() {
238        let builder = ContextBuilder::new(1000);
239        let output = builder.build();
240        assert!(output.is_empty());
241    }
242
243    #[test]
244    fn context_section_render_partial_memory() {
245        let results = vec![
246            MemoryResult { content: "a".repeat(500), score: 0.9, source: "s1".into(), confidence: 0.8 },
247            MemoryResult { content: "b".repeat(500), score: 0.5, source: "s2".into(), confidence: 0.7 },
248        ];
249        let section = ContextSection::Memory { results };
250        // Very small budget should truncate
251        let partial = section.render_partial(10);
252        assert!(partial.len() < section.render().len());
253    }
254}