Skip to main content

baml_agent/
helpers.rs

1//! Reusable helpers for SGR agent implementations.
2//!
3//! Common patterns extracted from va-agent, rc-cli, and other BAML-based agents.
4
5use crate::agent_loop::ActionResult;
6
7/// Normalize BAML-generated enum variant names.
8///
9/// BAML generates Rust enum variants with a `K` prefix (e.g. `Ksystem`, `Kdefault`).
10/// This strips it and lowercases for use in IO adapters, signatures, and display.
11///
12/// ```
13/// use baml_agent::helpers::norm;
14/// assert_eq!(norm("Ksystem"), "system");
15/// assert_eq!(norm("Kdefault"), "default");
16/// assert_eq!(norm("already_clean"), "already_clean");
17/// ```
18pub fn norm(v: &str) -> String {
19    if let Some(stripped) = v.strip_prefix("K") {
20        stripped.to_ascii_lowercase()
21    } else {
22        v.to_string()
23    }
24}
25
26/// Same as [`norm`] but takes an owned String (convenience for `format!("{:?}", variant)`).
27pub fn norm_owned(v: String) -> String {
28    if let Some(stripped) = v.strip_prefix('K') {
29        stripped.to_ascii_lowercase()
30    } else {
31        v
32    }
33}
34
35/// Build an `ActionResult` from a JSON value (non-terminal action).
36///
37/// Common pattern: `execute_xxx() → serde_json::Value → ActionResult`.
38pub fn action_result_json(value: &serde_json::Value) -> ActionResult {
39    ActionResult {
40        output: serde_json::to_string(value).unwrap_or_default(),
41        done: false,
42    }
43}
44
45/// Build an `ActionResult` from a `Result<Value, E>` (non-terminal).
46///
47/// On error, wraps in `{"error": "..."}`.
48pub fn action_result_from<E: std::fmt::Display>(
49    result: Result<serde_json::Value, E>,
50) -> ActionResult {
51    match result {
52        Ok(v) => action_result_json(&v),
53        Err(e) => action_result_json(&serde_json::json!({"error": e.to_string()})),
54    }
55}
56
57/// Build a terminal `ActionResult` (signals loop completion).
58pub fn action_result_done(summary: &str) -> ActionResult {
59    ActionResult {
60        output: summary.to_string(),
61        done: true,
62    }
63}
64
65/// Truncate a JSON array in-place, appending a note about total count.
66///
67/// Useful for keeping context window manageable (segments, beats, etc.).
68///
69/// ```
70/// use baml_agent::helpers::truncate_json_array;
71/// let mut v = serde_json::json!({"items": [1,2,3,4,5,6,7,8,9,10,11,12]});
72/// truncate_json_array(&mut v, "items", 3);
73/// assert_eq!(v["items"].as_array().unwrap().len(), 4); // 3 items + note
74/// ```
75pub fn truncate_json_array(value: &mut serde_json::Value, key: &str, max: usize) {
76    if let Some(arr) = value.get_mut(key).and_then(|v| v.as_array_mut()) {
77        let total = arr.len();
78        if total > max {
79            arr.truncate(max);
80            arr.push(serde_json::json!(format!(
81                "... showing {} of {} total",
82                max, total
83            )));
84        }
85    }
86}
87
88/// Load agent manifesto from standard CWD paths.
89///
90/// Checks `agent.md` and `.director/agent.md` in the current directory.
91/// Returns empty string if none found.
92pub fn load_manifesto() -> String {
93    load_manifesto_from(std::path::Path::new("."))
94}
95
96/// Load agent manifesto from a specific directory.
97pub fn load_manifesto_from(dir: &std::path::Path) -> String {
98    for name in &["agent.md", ".director/agent.md"] {
99        let path = dir.join(name);
100        if let Ok(content) = std::fs::read_to_string(&path) {
101            return format!("Project Agent Manifesto:\n---\n{}\n---", content);
102        }
103    }
104    String::new()
105}
106
107/// Agent context — layered memory system compatible with Claude Code.
108///
109/// ## Two loading modes
110///
111/// ### 1. Agent home dir (`load`)
112///
113/// Each agent has a home dir (e.g. `.my-agent/`).
114/// Inside it, markdown files provide agent-specific context:
115///
116/// | File | Label | What |
117/// |------|-------|------|
118/// | `SOUL.md` | Soul | Who the agent is: values, boundaries, tone |
119/// | `IDENTITY.md` | Identity | Name, role, stack, domain |
120/// | `MANIFESTO.md` | Manifesto | Dev principles, harness engineering |
121/// | `RULES.md` | Rules | Coding rules, workflow, constraints |
122/// | `MEMORY.md` | Memory | Cross-session learnings, preferences |
123/// | `context/*.md` | (filename) | User-extensible extras |
124///
125/// ### 2. Project dir (`load_project`) — Claude Code compatible
126///
127/// Loads project-level instructions from standard locations.
128/// Prefers `AGENTS.md` (generic) with fallback to `CLAUDE.md` (Claude Code compat).
129///
130/// | Priority | File | Scope |
131/// |----------|------|-------|
132/// | 1 | `AGENTS.md` / `CLAUDE.md` / `.claude/CLAUDE.md` | Project instructions (git) |
133/// | 2 | `AGENTS.local.md` / `CLAUDE.local.md` | Local instructions (gitignored) |
134/// | 3 | `.agents/rules/*.md` / `.claude/rules/*.md` | Rules by topic |
135///
136/// All files are optional. Missing files are silently skipped.
137#[derive(Debug, Default)]
138pub struct AgentContext {
139    /// Combined context text for system message injection.
140    pub parts: Vec<(String, String)>, // (label, content)
141}
142
143impl AgentContext {
144    /// Load context from an agent home directory (SOUL, IDENTITY, MANIFESTO, etc.).
145    pub fn load(home_dir: &str) -> Self {
146        let dir = std::path::Path::new(home_dir);
147        let mut ctx = Self::default();
148
149        const KNOWN_FILES: &[(&str, &str)] = &[
150            ("SOUL.md", "Soul"),
151            ("IDENTITY.md", "Identity"),
152            ("MANIFESTO.md", "Manifesto"),
153            ("RULES.md", "Rules"),
154            ("MEMORY.md", "Memory (user notes)"),
155        ];
156
157        for (filename, label) in KNOWN_FILES {
158            let path = dir.join(filename);
159            if let Ok(content) = std::fs::read_to_string(&path) {
160                if !content.trim().is_empty() {
161                    ctx.parts.push((label.to_string(), content));
162                }
163            }
164        }
165
166        // Typed memory from MEMORY.jsonl (agent-written, structured)
167        let jsonl_path = dir.join("MEMORY.jsonl");
168        if let Some(formatted) = format_memory_jsonl(&jsonl_path) {
169            ctx.parts.push(("Memory (learned)".to_string(), formatted));
170        }
171
172        // Extra context files from context/ subdir
173        load_rules_dir(&dir.join("context"), &mut ctx);
174
175        ctx
176    }
177
178    /// Load project-level context (AGENTS.md/CLAUDE.md + rules).
179    ///
180    /// Claude Code compatible: falls back to CLAUDE.md if AGENTS.md not found.
181    pub fn load_project(project_dir: &std::path::Path) -> Self {
182        let mut ctx = Self::default();
183
184        // 1. Project instructions: AGENTS.md > CLAUDE.md > .claude/CLAUDE.md
185        let project_files: &[(&str, &str)] = &[
186            ("AGENTS.md", "Project Instructions"),
187            ("CLAUDE.md", "Project Instructions"),
188            (".claude/CLAUDE.md", "Project Instructions"),
189        ];
190        for (filename, label) in project_files {
191            let path = project_dir.join(filename);
192            if let Ok(content) = std::fs::read_to_string(&path) {
193                if !content.trim().is_empty() {
194                    let expanded = expand_imports(&content, project_dir, 0);
195                    ctx.parts.push((label.to_string(), expanded));
196                    break; // first found wins
197                }
198            }
199        }
200
201        // 2. Local instructions: AGENTS.local.md > CLAUDE.local.md
202        let local_files: &[(&str, &str)] = &[
203            ("AGENTS.local.md", "Local Instructions"),
204            ("CLAUDE.local.md", "Local Instructions"),
205        ];
206        for (filename, label) in local_files {
207            let path = project_dir.join(filename);
208            if let Ok(content) = std::fs::read_to_string(&path) {
209                if !content.trim().is_empty() {
210                    let expanded = expand_imports(&content, project_dir, 0);
211                    ctx.parts.push((label.to_string(), expanded));
212                    break;
213                }
214            }
215        }
216
217        // 3. Rules: .agents/rules/*.md > .claude/rules/*.md
218        let rules_dirs = [
219            project_dir.join(".agents/rules"),
220            project_dir.join(".claude/rules"),
221        ];
222        for rules_dir in &rules_dirs {
223            if rules_dir.is_dir() {
224                load_rules_dir(rules_dir, &mut ctx);
225                break; // first found dir wins
226            }
227        }
228
229        ctx
230    }
231
232    /// Merge another context into this one (appends parts).
233    pub fn merge(&mut self, other: Self) {
234        self.parts.extend(other.parts);
235    }
236
237    /// Whether any context was found.
238    pub fn is_empty(&self) -> bool {
239        self.parts.is_empty()
240    }
241
242    /// Combine all parts into a single string for system message injection.
243    pub fn to_system_message(&self) -> Option<String> {
244        if self.parts.is_empty() {
245            return None;
246        }
247        let sections: Vec<String> = self
248            .parts
249            .iter()
250            .map(|(label, content)| format!("## {}\n{}", label, content.trim()))
251            .collect();
252        Some(sections.join("\n\n"))
253    }
254
255    /// Combine parts with a token budget (chars/4 estimate).
256    ///
257    /// Priority order (lowest dropped first):
258    /// 1. Memory (learned) — tentative entries already GC'd
259    /// 2. context/* extras
260    /// 3. Manifesto
261    /// 4. Rules, Identity, Project/Local Instructions
262    /// 5. Soul, Memory (user notes) — never dropped
263    pub fn to_system_message_with_budget(&self, max_tokens: usize) -> Option<String> {
264        if self.parts.is_empty() {
265            return None;
266        }
267
268        // Priority: higher = keep longer. Soul and user memory are sacred.
269        fn priority(label: &str) -> u8 {
270            match label {
271                "Soul" => 10,
272                "Memory (user notes)" => 9,
273                "Identity" => 8,
274                "Rules" => 8,
275                "Project Instructions" | "Local Instructions" => 7,
276                "Memory (learned)" => 6,
277                "Manifesto" => 5,
278                _ => 3, // context/* extras, rules/*
279            }
280        }
281
282        let mut indexed: Vec<(u8, &str, &str)> = self
283            .parts
284            .iter()
285            .map(|(label, content)| (priority(label), label.as_str(), content.as_str()))
286            .collect();
287        // Sort by priority descending — we'll drop from the end
288        indexed.sort_by(|a, b| b.0.cmp(&a.0));
289
290        let max_chars = max_tokens * 4;
291        let mut total_chars: usize = indexed.iter().map(|(_, l, c)| l.len() + c.len() + 10).sum();
292
293        // Drop lowest priority parts until we fit
294        while total_chars > max_chars && !indexed.is_empty() {
295            let last = indexed.last().unwrap();
296            if last.0 >= 9 {
297                break;
298            } // never drop Soul or user memory
299            total_chars -= last.1.len() + last.2.len() + 10;
300            indexed.pop();
301        }
302
303        if indexed.is_empty() {
304            return None;
305        }
306
307        // Restore original order for readability
308        indexed.sort_by(|a, b| b.0.cmp(&a.0));
309        let sections: Vec<String> = indexed
310            .iter()
311            .map(|(_, label, content)| format!("## {}\n{}", label, content.trim()))
312            .collect();
313        Some(sections.join("\n\n"))
314    }
315}
316
317/// Format MEMORY.jsonl into a readable system message.
318///
319/// - GC: tentative entries older than 7 days are auto-removed from file
320/// - Groups entries by section, shows category and confidence
321/// - Limits to last 50 entries to keep context manageable
322fn format_memory_jsonl(path: &std::path::Path) -> Option<String> {
323    let content = std::fs::read_to_string(path).ok()?;
324    let mut entries: Vec<serde_json::Value> = content
325        .lines()
326        .filter_map(|line| serde_json::from_str(line).ok())
327        .collect();
328    if entries.is_empty() {
329        return None;
330    }
331
332    // GC: remove tentative entries older than 7 days
333    let now_secs = std::time::SystemTime::now()
334        .duration_since(std::time::UNIX_EPOCH)
335        .unwrap_or_default()
336        .as_secs();
337    let seven_days = 7 * 24 * 3600;
338    let before_gc = entries.len();
339    entries.retain(|e| {
340        let confidence = e["confidence"].as_str().unwrap_or("tentative");
341        if confidence == "confirmed" {
342            return true;
343        }
344        let created = e["created"].as_u64().unwrap_or(now_secs);
345        now_secs.saturating_sub(created) < seven_days
346    });
347
348    // Write back if GC removed anything
349    if entries.len() < before_gc {
350        let lines: Vec<String> = entries
351            .iter()
352            .filter_map(|e| serde_json::to_string(e).ok())
353            .collect();
354        let _ = std::fs::write(path, lines.join("\n") + "\n");
355    }
356
357    if entries.is_empty() {
358        return None;
359    }
360
361    // Keep last 50 entries
362    let entries = if entries.len() > 50 {
363        &entries[entries.len() - 50..]
364    } else {
365        &entries[..]
366    };
367    let mut sections: std::collections::BTreeMap<String, Vec<String>> =
368        std::collections::BTreeMap::new();
369    for entry in entries {
370        let section = entry["section"].as_str().unwrap_or("General").to_string();
371        let category = entry["category"].as_str().unwrap_or("note");
372        let confidence = entry["confidence"].as_str().unwrap_or("tentative");
373        let content = entry["content"].as_str().unwrap_or("");
374        let marker = if confidence == "confirmed" {
375            "✓"
376        } else {
377            "?"
378        };
379        sections
380            .entry(section)
381            .or_default()
382            .push(format!("- [{}|{}] {}", marker, category, content));
383    }
384
385    let mut out = String::new();
386    for (section, items) in &sections {
387        out.push_str(&format!("### {}\n", section));
388        for item in items {
389            out.push_str(item);
390            out.push('\n');
391        }
392        out.push('\n');
393    }
394    Some(out)
395}
396
397/// Expand `@path/to/file` imports in content (Claude Code compatible).
398///
399/// Replaces `@relative/path` with the file contents inline.
400/// Max depth 5 to prevent cycles. Relative paths resolve from `base_dir`.
401fn expand_imports(content: &str, base_dir: &std::path::Path, depth: u8) -> String {
402    if depth > 5 {
403        return content.to_string();
404    }
405
406    let mut result = String::with_capacity(content.len());
407    for line in content.lines() {
408        let trimmed = line.trim();
409        // Match standalone @path or @path in text: "See @README.md for details"
410        let expanded = expand_line_imports(trimmed, base_dir, depth);
411        result.push_str(&expanded);
412        result.push('\n');
413    }
414    result
415}
416
417/// Expand @references within a single line.
418fn expand_line_imports(line: &str, base_dir: &std::path::Path, depth: u8) -> String {
419    let mut result = String::new();
420    let mut rest = line;
421
422    while let Some(at_pos) = rest.find('@') {
423        result.push_str(&rest[..at_pos]);
424        let after_at = &rest[at_pos + 1..];
425
426        // Extract path: sequence of non-whitespace chars after @
427        let path_end = after_at
428            .find(|c: char| c.is_whitespace() || c == ',' || c == ')' || c == ']')
429            .unwrap_or(after_at.len());
430        let ref_path = &after_at[..path_end];
431
432        if ref_path.is_empty() || ref_path.starts_with('{') {
433            // Not a file ref (e.g. @{variable})
434            result.push('@');
435            rest = after_at;
436            continue;
437        }
438
439        // Resolve path
440        let resolved = if ref_path.starts_with('~') {
441            let home = std::env::var("HOME").unwrap_or_default();
442            std::path::PathBuf::from(ref_path.replacen('~', &home, 1))
443        } else {
444            base_dir.join(ref_path)
445        };
446
447        if resolved.is_file() {
448            if let Ok(file_content) = std::fs::read_to_string(&resolved) {
449                let parent = resolved.parent().unwrap_or(base_dir);
450                let expanded = expand_imports(&file_content, parent, depth + 1);
451                result.push_str(expanded.trim());
452            } else {
453                result.push('@');
454                result.push_str(ref_path);
455            }
456        } else {
457            // Not a file — keep as-is (could be @mention or similar)
458            result.push('@');
459            result.push_str(ref_path);
460        }
461
462        rest = &after_at[path_end..];
463    }
464    result.push_str(rest);
465    result
466}
467
468/// Load all `*.md` files from a directory, sorted alphabetically.
469fn load_rules_dir(dir: &std::path::Path, ctx: &mut AgentContext) {
470    if !dir.is_dir() {
471        return;
472    }
473    if let Ok(entries) = std::fs::read_dir(dir) {
474        let mut files: Vec<_> = entries
475            .filter_map(|e| e.ok())
476            .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
477            .collect();
478        files.sort_by_key(|e| e.file_name());
479
480        for entry in files {
481            if let Ok(content) = std::fs::read_to_string(entry.path()) {
482                if !content.trim().is_empty() {
483                    let label = entry
484                        .path()
485                        .file_stem()
486                        .and_then(|s| s.to_str())
487                        .unwrap_or("rule")
488                        .to_string();
489                    ctx.parts.push((label, content));
490                }
491            }
492        }
493    }
494}
495
496/// Load all `.md` files from a directory (flat, no convention).
497///
498/// Simpler alternative to [`AgentContext`] when you just need raw file concat.
499pub fn load_context_dir(dir: &str) -> Option<String> {
500    let ctx = AgentContext::load(dir);
501    ctx.to_system_message()
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn norm_strips_k_prefix() {
510        assert_eq!(norm("Ksystem"), "system");
511        assert_eq!(norm("Kuser"), "user");
512        assert_eq!(norm("Kassistant"), "assistant");
513        assert_eq!(norm("Kdefault"), "default");
514        assert_eq!(norm("Karchive_master"), "archive_master");
515    }
516
517    #[test]
518    fn norm_preserves_clean_values() {
519        assert_eq!(norm("system"), "system");
520        assert_eq!(norm("already_clean"), "already_clean");
521        assert_eq!(norm(""), "");
522    }
523
524    #[test]
525    fn action_result_json_works() {
526        let val = serde_json::json!({"ok": true, "count": 5});
527        let ar = action_result_json(&val);
528        assert!(!ar.done);
529        assert!(ar.output.contains("\"ok\":true") || ar.output.contains("\"ok\": true"));
530    }
531
532    #[test]
533    fn action_result_from_error() {
534        let err: Result<serde_json::Value, String> = Err("something broke".into());
535        let ar = action_result_from(err);
536        assert!(!ar.done);
537        assert!(ar.output.contains("something broke"));
538    }
539
540    #[test]
541    fn action_result_done_sets_flag() {
542        let ar = action_result_done("all complete");
543        assert!(ar.done);
544        assert_eq!(ar.output, "all complete");
545    }
546
547    #[test]
548    fn truncate_json_array_works() {
549        let mut v = serde_json::json!({"items": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]});
550        truncate_json_array(&mut v, "items", 3);
551        let arr = v["items"].as_array().unwrap();
552        assert_eq!(arr.len(), 4); // 3 + note
553        assert!(arr[3].as_str().unwrap().contains("12 total"));
554    }
555
556    #[test]
557    fn truncate_json_array_noop_if_small() {
558        let mut v = serde_json::json!({"items": [1, 2, 3]});
559        truncate_json_array(&mut v, "items", 10);
560        assert_eq!(v["items"].as_array().unwrap().len(), 3);
561    }
562
563    #[test]
564    fn truncate_json_array_missing_key_noop() {
565        let mut v = serde_json::json!({"other": "value"});
566        truncate_json_array(&mut v, "items", 3);
567        assert!(v.get("items").is_none());
568    }
569
570    #[test]
571    fn load_manifesto_returns_empty_when_not_found() {
572        let m = load_manifesto_from(std::path::Path::new("/nonexistent"));
573        assert!(m.is_empty());
574    }
575
576    #[test]
577    fn agent_context_loads_known_files() {
578        let dir = std::env::temp_dir().join("baml_test_agent_ctx");
579        let _ = std::fs::remove_dir_all(&dir);
580        std::fs::create_dir_all(&dir).unwrap();
581        std::fs::write(dir.join("SOUL.md"), "Be direct and honest.").unwrap();
582        std::fs::write(
583            dir.join("IDENTITY.md"),
584            "Name: rust-code\nRole: coding agent",
585        )
586        .unwrap();
587        std::fs::write(dir.join("MANIFESTO.md"), "TDD first. Ship > perfect.").unwrap();
588
589        let ctx = AgentContext::load(dir.to_str().unwrap());
590        assert_eq!(ctx.parts.len(), 3);
591        assert_eq!(ctx.parts[0].0, "Soul");
592        assert_eq!(ctx.parts[1].0, "Identity");
593        assert_eq!(ctx.parts[2].0, "Manifesto");
594
595        let msg = ctx.to_system_message().unwrap();
596        assert!(msg.contains("Be direct"));
597        assert!(msg.contains("rust-code"));
598        assert!(msg.contains("TDD first"));
599
600        let _ = std::fs::remove_dir_all(&dir);
601    }
602
603    #[test]
604    fn agent_context_loads_extras_from_context_dir() {
605        let dir = std::env::temp_dir().join("baml_test_agent_ctx_extras");
606        let _ = std::fs::remove_dir_all(&dir);
607        let ctx_dir = dir.join("context");
608        std::fs::create_dir_all(&ctx_dir).unwrap();
609        std::fs::write(dir.join("RULES.md"), "Validate at boundaries.").unwrap();
610        std::fs::write(ctx_dir.join("stacks.md"), "Rust + Tokio").unwrap();
611        std::fs::write(ctx_dir.join("ignore.txt"), "not loaded").unwrap();
612
613        let ctx = AgentContext::load(dir.to_str().unwrap());
614        assert_eq!(ctx.parts.len(), 2); // RULES + stacks
615        assert_eq!(ctx.parts[1].0, "stacks");
616
617        let msg = ctx.to_system_message().unwrap();
618        assert!(msg.contains("Validate at boundaries"));
619        assert!(msg.contains("Rust + Tokio"));
620        assert!(!msg.contains("not loaded"));
621
622        let _ = std::fs::remove_dir_all(&dir);
623    }
624
625    #[test]
626    fn agent_context_empty_when_no_dir() {
627        let ctx = AgentContext::load("/nonexistent/path");
628        assert!(ctx.is_empty());
629        assert!(ctx.to_system_message().is_none());
630    }
631
632    #[test]
633    fn load_project_prefers_agents_md() {
634        let dir = std::env::temp_dir().join("baml_test_project_agents");
635        let _ = std::fs::remove_dir_all(&dir);
636        std::fs::create_dir_all(&dir).unwrap();
637        std::fs::write(dir.join("AGENTS.md"), "Use pnpm.").unwrap();
638        std::fs::write(dir.join("CLAUDE.md"), "Use npm.").unwrap();
639
640        let ctx = AgentContext::load_project(&dir);
641        assert_eq!(ctx.parts.len(), 1);
642        assert_eq!(ctx.parts[0].0, "Project Instructions");
643        assert!(ctx.parts[0].1.contains("pnpm")); // AGENTS.md wins
644
645        let _ = std::fs::remove_dir_all(&dir);
646    }
647
648    #[test]
649    fn load_project_falls_back_to_claude_md() {
650        let dir = std::env::temp_dir().join("baml_test_project_claude");
651        let _ = std::fs::remove_dir_all(&dir);
652        std::fs::create_dir_all(&dir).unwrap();
653        std::fs::write(dir.join("CLAUDE.md"), "Build with cargo.").unwrap();
654
655        let ctx = AgentContext::load_project(&dir);
656        assert_eq!(ctx.parts.len(), 1);
657        assert!(ctx.parts[0].1.contains("cargo"));
658
659        let _ = std::fs::remove_dir_all(&dir);
660    }
661
662    #[test]
663    fn load_project_loads_local_and_rules() {
664        let dir = std::env::temp_dir().join("baml_test_project_full");
665        let _ = std::fs::remove_dir_all(&dir);
666        let rules_dir = dir.join(".claude/rules");
667        std::fs::create_dir_all(&rules_dir).unwrap();
668        std::fs::write(dir.join("CLAUDE.md"), "Project X").unwrap();
669        std::fs::write(dir.join("CLAUDE.local.md"), "My sandbox URL").unwrap();
670        std::fs::write(rules_dir.join("testing.md"), "Run pytest").unwrap();
671        std::fs::write(rules_dir.join("style.md"), "Use black").unwrap();
672
673        let ctx = AgentContext::load_project(&dir);
674        assert_eq!(ctx.parts.len(), 4); // CLAUDE + local + 2 rules
675        assert_eq!(ctx.parts[0].0, "Project Instructions");
676        assert_eq!(ctx.parts[1].0, "Local Instructions");
677        // Rules sorted alphabetically
678        assert_eq!(ctx.parts[2].0, "style");
679        assert_eq!(ctx.parts[3].0, "testing");
680
681        let _ = std::fs::remove_dir_all(&dir);
682    }
683
684    #[test]
685    fn load_project_agents_rules_over_claude_rules() {
686        let dir = std::env::temp_dir().join("baml_test_project_agents_rules");
687        let _ = std::fs::remove_dir_all(&dir);
688        std::fs::create_dir_all(dir.join(".agents/rules")).unwrap();
689        std::fs::create_dir_all(dir.join(".claude/rules")).unwrap();
690        std::fs::write(dir.join(".agents/rules/main.md"), "Agents rule").unwrap();
691        std::fs::write(dir.join(".claude/rules/main.md"), "Claude rule").unwrap();
692
693        let ctx = AgentContext::load_project(&dir);
694        assert_eq!(ctx.parts.len(), 1);
695        assert!(ctx.parts[0].1.contains("Agents rule")); // .agents/ wins
696
697        let _ = std::fs::remove_dir_all(&dir);
698    }
699
700    #[test]
701    fn memory_jsonl_loaded_into_context() {
702        let dir = std::env::temp_dir().join("baml_test_memory_jsonl");
703        let _ = std::fs::remove_dir_all(&dir);
704        std::fs::create_dir_all(&dir).unwrap();
705        std::fs::write(dir.join("SOUL.md"), "Be direct.").unwrap();
706        std::fs::write(dir.join("MEMORY.jsonl"), concat!(
707            r#"{"category":"decision","section":"Build System","content":"Use cargo, not make","context":"tested both","confidence":"confirmed","created":1772700000}"#, "\n",
708            r#"{"category":"pattern","section":"Build System","content":"Always run check before test","context":null,"confidence":"tentative","created":1772700100}"#, "\n",
709            r#"{"category":"preference","section":"Style","content":"User prefers short commits","context":"observed","confidence":"confirmed","created":1772700200}"#, "\n",
710        )).unwrap();
711
712        let ctx = AgentContext::load(dir.to_str().unwrap());
713        // SOUL + Memory (learned)
714        assert!(ctx.parts.iter().any(|(l, _)| l == "Memory (learned)"));
715        let mem = ctx
716            .parts
717            .iter()
718            .find(|(l, _)| l == "Memory (learned)")
719            .unwrap();
720        assert!(mem.1.contains("Use cargo, not make"));
721        assert!(mem.1.contains("[✓|decision]")); // confirmed
722        assert!(mem.1.contains("[?|pattern]")); // tentative
723        assert!(mem.1.contains("### Style"));
724
725        let _ = std::fs::remove_dir_all(&dir);
726    }
727
728    #[test]
729    fn memory_jsonl_missing_is_ok() {
730        let dir = std::env::temp_dir().join("baml_test_no_jsonl");
731        let _ = std::fs::remove_dir_all(&dir);
732        std::fs::create_dir_all(&dir).unwrap();
733        std::fs::write(dir.join("SOUL.md"), "Be direct.").unwrap();
734
735        let ctx = AgentContext::load(dir.to_str().unwrap());
736        assert!(!ctx.parts.iter().any(|(l, _)| l.contains("learned")));
737
738        let _ = std::fs::remove_dir_all(&dir);
739    }
740
741    #[test]
742    fn merge_combines_contexts() {
743        let mut a = AgentContext::default();
744        a.parts.push(("Soul".into(), "Be direct.".into()));
745
746        let mut b = AgentContext::default();
747        b.parts.push(("Project".into(), "Use Rust.".into()));
748
749        a.merge(b);
750        assert_eq!(a.parts.len(), 2);
751        assert_eq!(a.parts[0].0, "Soul");
752        assert_eq!(a.parts[1].0, "Project");
753    }
754
755    #[test]
756    fn gc_removes_old_tentative_entries() {
757        let dir = std::env::temp_dir().join("baml_test_memory_gc");
758        let _ = std::fs::remove_dir_all(&dir);
759        std::fs::create_dir_all(&dir).unwrap();
760
761        let now = std::time::SystemTime::now()
762            .duration_since(std::time::UNIX_EPOCH)
763            .unwrap()
764            .as_secs();
765        let old = now - 8 * 24 * 3600; // 8 days ago
766
767        let entries = format!(
768            "{}\n{}\n{}\n",
769            serde_json::json!({"category":"decision","section":"A","content":"confirmed old","confidence":"confirmed","created":old}),
770            serde_json::json!({"category":"pattern","section":"B","content":"tentative old","confidence":"tentative","created":old}),
771            serde_json::json!({"category":"insight","section":"C","content":"tentative recent","confidence":"tentative","created":now}),
772        );
773        let path = dir.join("MEMORY.jsonl");
774        std::fs::write(&path, &entries).unwrap();
775
776        let formatted = format_memory_jsonl(&path).unwrap();
777        // Old tentative should be GC'd
778        assert!(!formatted.contains("tentative old"));
779        // Confirmed old stays
780        assert!(formatted.contains("confirmed old"));
781        // Recent tentative stays
782        assert!(formatted.contains("tentative recent"));
783
784        // File should be rewritten without old tentative
785        let remaining = std::fs::read_to_string(&path).unwrap();
786        assert!(!remaining.contains("tentative old"));
787        assert_eq!(remaining.lines().count(), 2);
788
789        let _ = std::fs::remove_dir_all(&dir);
790    }
791
792    #[test]
793    fn import_expands_file_refs() {
794        let dir = std::env::temp_dir().join("baml_test_import");
795        let _ = std::fs::remove_dir_all(&dir);
796        std::fs::create_dir_all(&dir).unwrap();
797        std::fs::write(dir.join("README.md"), "# My Project\nThis is the readme.").unwrap();
798        std::fs::write(
799            dir.join("CLAUDE.md"),
800            "See @README.md for overview.\nDo stuff.",
801        )
802        .unwrap();
803
804        let ctx = AgentContext::load_project(&dir);
805        let msg = ctx.to_system_message().unwrap();
806        assert!(msg.contains("This is the readme")); // imported
807        assert!(msg.contains("Do stuff")); // original content preserved
808
809        let _ = std::fs::remove_dir_all(&dir);
810    }
811
812    #[test]
813    fn import_nonexistent_file_kept_as_is() {
814        let dir = std::env::temp_dir().join("baml_test_import_missing");
815        let _ = std::fs::remove_dir_all(&dir);
816        std::fs::create_dir_all(&dir).unwrap();
817        std::fs::write(dir.join("CLAUDE.md"), "See @nonexistent.md for info.").unwrap();
818
819        let ctx = AgentContext::load_project(&dir);
820        let msg = ctx.to_system_message().unwrap();
821        assert!(msg.contains("@nonexistent.md")); // kept as-is
822
823        let _ = std::fs::remove_dir_all(&dir);
824    }
825
826    #[test]
827    fn token_budget_drops_low_priority() {
828        let mut ctx = AgentContext::default();
829        ctx.parts.push(("Soul".into(), "Be direct.".into())); // priority 10
830        ctx.parts.push(("Manifesto".into(), "x".repeat(10000))); // priority 5, big
831        ctx.parts.push(("Identity".into(), "Name: test".into())); // priority 8
832
833        // Budget that fits Soul + Identity but not Manifesto
834        let msg = ctx.to_system_message_with_budget(100).unwrap(); // ~400 chars budget
835        assert!(msg.contains("Be direct")); // Soul kept
836        assert!(msg.contains("Name: test")); // Identity kept
837        assert!(!msg.contains("xxxxxxxxx")); // Manifesto dropped
838    }
839
840    #[test]
841    fn token_budget_never_drops_soul() {
842        let mut ctx = AgentContext::default();
843        ctx.parts.push(("Soul".into(), "x".repeat(5000)));
844
845        // Even tiny budget keeps Soul
846        let msg = ctx.to_system_message_with_budget(10).unwrap();
847        assert!(msg.contains("xxxxx"));
848    }
849}