Skip to main content

roboticus_agent/
learning.rs

1//! Learning loop — detect successful multi-step tool sequences from completed
2//! sessions and synthesize reusable skill documents.
3//!
4//! # Architecture
5//!
6//! When a session closes (TTL expiry or rotation), the governor calls
7//! [`learn_on_close`].  This function:
8//!
9//! 1. Loads all tool calls for the session via `get_tool_calls_for_session()`
10//! 2. Flattens them chronologically and runs [`detect_candidate_procedures`]
11//! 3. For each candidate, either reinforces an existing learned skill or
12//!    synthesises a new SKILL.md and persists it
13//!
14//! No LLM call is involved — learning is pure template synthesis from observed
15//! tool-call data, keeping the hot path fast and deterministic.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use roboticus_core::config::LearningConfig;
21use roboticus_db::Database;
22use roboticus_db::sessions::Session;
23use roboticus_db::tools::ToolCallRecord;
24use tracing::{debug, info, warn};
25
26// ── Types ──────────────────────────────────────────────────────
27
28/// A candidate procedure detected from a session's tool-call history.
29#[derive(Debug, Clone)]
30pub struct CandidateProcedure {
31    /// Auto-generated name based on the tool chain (e.g. "read-edit-bash").
32    pub name: String,
33    /// Human-readable description.
34    pub description: String,
35    /// Ordered tool names that compose this procedure.
36    pub tool_sequence: Vec<String>,
37    /// Fraction of tools in the sequence that succeeded (0.0–1.0).
38    pub success_ratio: f64,
39    /// The individual steps with input/output summaries.
40    pub steps: Vec<ProcedureStep>,
41}
42
43/// A single step within a candidate procedure.
44#[derive(Debug, Clone)]
45pub struct ProcedureStep {
46    pub tool_name: String,
47    pub input_summary: String,
48    pub output_summary: Option<String>,
49    pub status: String,
50}
51
52// ── Detection ──────────────────────────────────────────────────
53
54/// Flatten tool calls from all turns into chronological order, then detect
55/// consecutive sequences of ≥ `min_length` tools where the success ratio
56/// meets the threshold.
57pub fn detect_candidate_procedures(
58    tool_calls_by_turn: &HashMap<String, Vec<ToolCallRecord>>,
59    min_length: usize,
60    min_success_ratio: f64,
61) -> Vec<CandidateProcedure> {
62    // Flatten all tool calls and sort by created_at.
63    let mut all_calls: Vec<&ToolCallRecord> =
64        tool_calls_by_turn.values().flat_map(|v| v.iter()).collect();
65    all_calls.sort_by(|a, b| a.created_at.cmp(&b.created_at));
66
67    if all_calls.len() < min_length {
68        return Vec::new();
69    }
70
71    // Sliding-window: find contiguous runs of ≥ min_length tools that meet
72    // the success threshold.  We skip trivially repeated single-tool runs
73    // (e.g. three consecutive "bash" calls).
74    let mut candidates: Vec<CandidateProcedure> = Vec::new();
75
76    // Try windows of min_length up to 2×min_length (cap to avoid noise).
77    // Also cap the input to the last 200 tool calls to avoid quadratic blowup
78    // on very long sessions.
79    let max_input = 200;
80    let all_calls = if all_calls.len() > max_input {
81        &all_calls[all_calls.len() - max_input..]
82    } else {
83        &all_calls[..]
84    };
85    let max_window = (min_length * 2).min(all_calls.len());
86    for window_size in min_length..=max_window {
87        for window in all_calls.windows(window_size) {
88            let success_count = window.iter().filter(|c| c.status == "success").count();
89            let ratio = success_count as f64 / window.len() as f64;
90            if ratio < min_success_ratio {
91                continue;
92            }
93
94            let tool_seq: Vec<String> = window.iter().map(|c| c.tool_name.clone()).collect();
95
96            // Skip if all tools are identical (e.g. ["bash","bash","bash"]).
97            let distinct: std::collections::HashSet<&str> =
98                tool_seq.iter().map(|s| s.as_str()).collect();
99            if distinct.len() < 2 {
100                continue;
101            }
102
103            // Use sanitized name as the dedup key — this matches the DB/filename key
104            // so two raw names that sanitize to the same string are correctly deduped.
105            let name = sanitize_name(&tool_seq.join("-"));
106            if candidates.iter().any(|c| c.name == name) {
107                continue;
108            }
109
110            let steps: Vec<ProcedureStep> = window
111                .iter()
112                .map(|c| ProcedureStep {
113                    tool_name: c.tool_name.clone(),
114                    input_summary: truncate(&c.input, 120),
115                    output_summary: c.output.as_deref().map(|o| truncate(o, 120)),
116                    status: c.status.clone(),
117                })
118                .collect();
119
120            let description = format!(
121                "{}-step procedure using {}",
122                steps.len(),
123                distinct_tools_display(&tool_seq),
124            );
125
126            candidates.push(CandidateProcedure {
127                name,
128                description,
129                tool_sequence: tool_seq,
130                success_ratio: ratio,
131                steps,
132            });
133        }
134    }
135
136    candidates
137}
138
139// ── Synthesis ──────────────────────────────────────────────────
140
141/// Generate a SKILL.md document from a candidate procedure.
142///
143/// Format: YAML frontmatter (`---` delimited, matching `parse_instruction_md`)
144/// followed by markdown body (steps, tool chain, when to use).
145pub fn synthesize_skill_md(candidate: &CandidateProcedure) -> String {
146    let triggers: Vec<&str> = {
147        let mut seen = std::collections::HashSet::new();
148        candidate
149            .tool_sequence
150            .iter()
151            .filter(|t| seen.insert(t.as_str()))
152            .map(|t| t.as_str())
153            .collect()
154    };
155
156    let mut md = String::new();
157
158    // YAML frontmatter (must use `---` delimiters to match SkillLoader)
159    md.push_str("---\n");
160    md.push_str(&format!("name: {}\n", sanitize_name(&candidate.name)));
161    md.push_str(&format!(
162        "description: \"{}\"\n",
163        candidate.description.replace('"', "'")
164    ));
165    // triggers as YAML list — quote values to handle special chars in tool names
166    // (e.g. "mcp:read_file", "[bash]", "tool/sub")
167    md.push_str("triggers:\n");
168    for t in &triggers {
169        md.push_str(&format!("  - \"{}\"\n", t.replace('"', "'")));
170    }
171    md.push_str("priority: 50\n");
172    md.push_str("version: \"0.0.1\"\n");
173    md.push_str("author: learned\n");
174    md.push_str("---\n\n");
175
176    // Markdown body
177    md.push_str(&format!("# {}\n\n", candidate.description));
178    md.push_str(&format!(
179        "Learned from a successful {}-step tool sequence.\n\n",
180        candidate.steps.len()
181    ));
182
183    md.push_str("## Steps\n\n");
184    for (i, step) in candidate.steps.iter().enumerate() {
185        md.push_str(&format!(
186            "{}. **{}** ({})\n",
187            i + 1,
188            step.tool_name,
189            step.status
190        ));
191        // Escape backticks in input/output to prevent broken inline code fences
192        let safe_input = step.input_summary.replace('`', "'");
193        md.push_str(&format!("   - Input: `{safe_input}`\n"));
194        if let Some(ref out) = step.output_summary {
195            let safe_output = out.replace('`', "'");
196            md.push_str(&format!("   - Output: `{safe_output}`\n"));
197        }
198    }
199    md.push('\n');
200
201    md.push_str("## When to Use\n\n");
202    md.push_str(&format!(
203        "This procedure applies when the agent needs to use {} in sequence.\n",
204        distinct_tools_display(&candidate.tool_sequence),
205    ));
206
207    md
208}
209
210/// Write a learned skill to disk under `{skills_dir}/learned/`.
211///
212/// Uses atomic write (write to `.tmp` then rename) so that a concurrent
213/// `SkillLoader` never observes a partially-written `.md` file.
214pub fn write_learned_skill(
215    skills_dir: &Path,
216    candidate: &CandidateProcedure,
217    md_content: &str,
218) -> roboticus_core::Result<PathBuf> {
219    let learned_dir = skills_dir.join("learned");
220    std::fs::create_dir_all(&learned_dir).map_err(|e| {
221        roboticus_core::RoboticusError::Config(format!("failed to create learned skills dir: {e}"))
222    })?;
223
224    let filename = format!("{}.md", sanitize_name(&candidate.name));
225    let path = learned_dir.join(&filename);
226    let tmp_path = learned_dir.join(format!("{filename}.tmp"));
227
228    // Write to temporary file first, then atomically rename into place.
229    std::fs::write(&tmp_path, md_content).map_err(|e| {
230        roboticus_core::RoboticusError::Config(format!("failed to write learned skill tmp: {e}"))
231    })?;
232    std::fs::rename(&tmp_path, &path).map_err(|e| {
233        // Clean up tmp on rename failure
234        let _ = std::fs::remove_file(&tmp_path);
235        roboticus_core::RoboticusError::Config(format!("failed to rename learned skill: {e}"))
236    })?;
237    Ok(path)
238}
239
240// ── Orchestrator ───────────────────────────────────────────────
241
242/// Main entry point: called by the governor when a session closes.
243///
244/// Mirrors the pattern of `digest_on_close()` — guard on config, extract data,
245/// synthesise artifacts, persist, log.
246pub fn learn_on_close(
247    db: &Database,
248    config: &LearningConfig,
249    session: &Session,
250    skills_dir: &Path,
251) {
252    if !config.enabled {
253        debug!(session_id = %session.id, "learning disabled");
254        return;
255    }
256
257    // Cap enforcement — track remaining capacity to avoid TOCTOU race where
258    // the loop could exceed max_learned_skills by inserting multiple candidates.
259    let remaining_capacity = match roboticus_db::learned_skills::count_learned_skills(db) {
260        Ok(count) if count >= config.max_learned_skills => {
261            debug!(
262                count,
263                max = config.max_learned_skills,
264                "learned skills cap reached, skipping"
265            );
266            return;
267        }
268        Ok(count) => config.max_learned_skills - count,
269        Err(e) => {
270            warn!(error = %e, "failed to count learned skills");
271            return;
272        }
273    };
274    let mut new_skills_inserted = 0usize;
275
276    // Load tool calls for this session
277    let tool_calls = match roboticus_db::tools::get_tool_calls_for_session(db, &session.id) {
278        Ok(tc) => tc,
279        Err(e) => {
280            warn!(error = %e, session_id = %session.id, "failed to load tool calls for learning");
281            return;
282        }
283    };
284
285    if tool_calls.is_empty() {
286        debug!(session_id = %session.id, "no tool calls to learn from");
287        return;
288    }
289
290    // Detect candidate procedures
291    let candidates = detect_candidate_procedures(
292        &tool_calls,
293        config.min_tool_sequence,
294        config.min_success_ratio,
295    );
296
297    if candidates.is_empty() {
298        debug!(session_id = %session.id, "no candidate procedures detected");
299        return;
300    }
301
302    for candidate in &candidates {
303        // Check if already known
304        match roboticus_db::learned_skills::get_learned_skill_by_name(db, &candidate.name) {
305            Ok(Some(existing)) => {
306                // Reinforce existing skill (doesn't count against cap)
307                if let Err(e) =
308                    roboticus_db::learned_skills::record_learned_skill_success(db, &candidate.name)
309                {
310                    warn!(error = %e, name = %candidate.name, "failed to reinforce learned skill");
311                }
312
313                // Self-heal: if a prior run stored the DB row but the .md write
314                // or path-set failed, the record has skill_md_path = NULL.
315                // Re-synthesize the .md so the skill is fully usable.
316                if existing.skill_md_path.is_none() {
317                    let md = synthesize_skill_md(candidate);
318                    match write_learned_skill(skills_dir, candidate, &md) {
319                        Ok(path) => {
320                            let path_str = path.to_string_lossy().to_string();
321                            if let Err(e) = roboticus_db::learned_skills::set_learned_skill_md_path(
322                                db,
323                                &candidate.name,
324                                &path_str,
325                            ) {
326                                warn!(error = %e, name = %candidate.name, "failed to set healed skill md path");
327                            } else {
328                                info!(name = %candidate.name, path = %path_str, "healed learned skill .md");
329                            }
330                        }
331                        Err(e) => {
332                            warn!(error = %e, name = %candidate.name, "failed to heal skill .md write");
333                        }
334                    }
335                }
336
337                debug!(name = %candidate.name, "reinforced existing learned skill");
338            }
339            Ok(None) => {
340                // Guard: check remaining capacity before inserting new skill
341                if new_skills_inserted >= remaining_capacity {
342                    debug!(
343                        inserted = new_skills_inserted,
344                        remaining = remaining_capacity,
345                        "learned skills cap reached during loop, stopping"
346                    );
347                    break;
348                }
349
350                // New skill: store in DB + write .md file
351                let trigger_tools_json =
352                    serde_json::to_string(&candidate.tool_sequence).unwrap_or_else(|_| "[]".into());
353                let steps_json = serde_json::to_string(&steps_to_serializable(&candidate.steps))
354                    .unwrap_or_else(|_| "[]".into());
355
356                if let Err(e) = roboticus_db::learned_skills::store_learned_skill(
357                    db,
358                    &candidate.name,
359                    &candidate.description,
360                    &trigger_tools_json,
361                    &steps_json,
362                    Some(&session.id),
363                ) {
364                    warn!(error = %e, name = %candidate.name, "failed to store learned skill");
365                    continue;
366                }
367                new_skills_inserted += 1;
368
369                // Synthesise and write .md (atomic: write to .tmp then rename)
370                let md = synthesize_skill_md(candidate);
371                match write_learned_skill(skills_dir, candidate, &md) {
372                    Ok(path) => {
373                        let path_str = path.to_string_lossy().to_string();
374                        if let Err(e) = roboticus_db::learned_skills::set_learned_skill_md_path(
375                            db,
376                            &candidate.name,
377                            &path_str,
378                        ) {
379                            warn!(error = %e, "failed to record skill md path");
380                        }
381                        info!(
382                            name = %candidate.name,
383                            path = %path_str,
384                            steps = candidate.steps.len(),
385                            "learned new skill"
386                        );
387                    }
388                    Err(e) => {
389                        warn!(error = %e, name = %candidate.name, "failed to write skill .md");
390                    }
391                }
392            }
393            Err(e) => {
394                warn!(error = %e, "failed to check existing learned skill");
395            }
396        }
397    }
398}
399
400// ── Helpers ────────────────────────────────────────────────────
401
402fn truncate(s: &str, max_len: usize) -> String {
403    if s.len() <= max_len {
404        s.to_string()
405    } else {
406        // Use floor_char_boundary to avoid panicking on multi-byte UTF-8.
407        let boundary = s.floor_char_boundary(max_len);
408        format!("{}…", &s[..boundary])
409    }
410}
411
412fn sanitize_name(name: &str) -> String {
413    let raw: String = name
414        .chars()
415        .map(|c| {
416            if c.is_alphanumeric() || c == '-' || c == '_' {
417                c
418            } else {
419                '-'
420            }
421        })
422        .collect::<String>()
423        .to_lowercase();
424
425    // Collapse runs of hyphens, trim leading/trailing hyphens, and provide
426    // a fallback so we never produce an empty or hidden filename.
427    let collapsed: String = raw
428        .split('-')
429        .filter(|s| !s.is_empty())
430        .collect::<Vec<_>>()
431        .join("-");
432
433    if collapsed.is_empty() {
434        "unknown-skill".to_string()
435    } else {
436        collapsed
437    }
438}
439
440fn distinct_tools_display(seq: &[String]) -> String {
441    let mut seen = std::collections::HashSet::new();
442    let unique: Vec<&str> = seq
443        .iter()
444        .filter(|t| seen.insert(t.as_str()))
445        .map(|t| t.as_str())
446        .collect();
447    unique.join(" → ")
448}
449
450/// Convert steps to a JSON-serializable form.
451fn steps_to_serializable(steps: &[ProcedureStep]) -> Vec<HashMap<String, String>> {
452    steps
453        .iter()
454        .map(|s| {
455            let mut m = HashMap::new();
456            m.insert("tool".into(), s.tool_name.clone());
457            m.insert("input".into(), s.input_summary.clone());
458            m.insert("status".into(), s.status.clone());
459            if let Some(ref out) = s.output_summary {
460                m.insert("output".into(), out.clone());
461            }
462            m
463        })
464        .collect()
465}
466
467// ── Tests ──────────────────────────────────────────────────────
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    fn make_call(name: &str, status: &str, time: &str) -> ToolCallRecord {
474        ToolCallRecord {
475            id: uuid::Uuid::new_v4().to_string(),
476            turn_id: "t1".into(),
477            tool_name: name.into(),
478            input: format!(r#"{{"action":"{name}"}}"#),
479            output: Some(format!("{name} output")),
480            skill_id: None,
481            skill_name: None,
482            skill_hash: None,
483            status: status.into(),
484            duration_ms: Some(50),
485            created_at: time.into(),
486        }
487    }
488
489    fn sample_calls() -> HashMap<String, Vec<ToolCallRecord>> {
490        let mut map = HashMap::new();
491        map.insert(
492            "t1".into(),
493            vec![
494                make_call("read", "success", "2025-01-01T00:00:01Z"),
495                make_call("edit", "success", "2025-01-01T00:00:02Z"),
496                make_call("bash", "success", "2025-01-01T00:00:03Z"),
497            ],
498        );
499        map
500    }
501
502    #[test]
503    fn detect_three_step_procedure() {
504        let calls = sample_calls();
505        let candidates = detect_candidate_procedures(&calls, 3, 0.7);
506        assert!(!candidates.is_empty());
507        let c = &candidates[0];
508        assert_eq!(c.tool_sequence, vec!["read", "edit", "bash"]);
509        assert!((c.success_ratio - 1.0).abs() < f64::EPSILON);
510        assert_eq!(c.steps.len(), 3);
511    }
512
513    #[test]
514    fn no_detection_below_min_length() {
515        let mut map = HashMap::new();
516        map.insert(
517            "t1".into(),
518            vec![
519                make_call("read", "success", "2025-01-01T00:00:01Z"),
520                make_call("edit", "success", "2025-01-01T00:00:02Z"),
521            ],
522        );
523        let candidates = detect_candidate_procedures(&map, 3, 0.7);
524        assert!(candidates.is_empty());
525    }
526
527    #[test]
528    fn no_detection_below_success_ratio() {
529        let mut map = HashMap::new();
530        map.insert(
531            "t1".into(),
532            vec![
533                make_call("read", "error", "2025-01-01T00:00:01Z"),
534                make_call("edit", "error", "2025-01-01T00:00:02Z"),
535                make_call("bash", "success", "2025-01-01T00:00:03Z"),
536            ],
537        );
538        // ratio = 1/3 = 0.33 < 0.7
539        let candidates = detect_candidate_procedures(&map, 3, 0.7);
540        assert!(candidates.is_empty());
541    }
542
543    #[test]
544    fn skip_identical_tools() {
545        let mut map = HashMap::new();
546        map.insert(
547            "t1".into(),
548            vec![
549                make_call("bash", "success", "2025-01-01T00:00:01Z"),
550                make_call("bash", "success", "2025-01-01T00:00:02Z"),
551                make_call("bash", "success", "2025-01-01T00:00:03Z"),
552            ],
553        );
554        let candidates = detect_candidate_procedures(&map, 3, 0.7);
555        assert!(
556            candidates.is_empty(),
557            "all-same-tool sequences should be skipped"
558        );
559    }
560
561    #[test]
562    fn synthesize_skill_md_format() {
563        let candidate = CandidateProcedure {
564            name: "read-edit-bash".into(),
565            description: "3-step procedure using read → edit → bash".into(),
566            tool_sequence: vec!["read".into(), "edit".into(), "bash".into()],
567            success_ratio: 1.0,
568            steps: vec![
569                ProcedureStep {
570                    tool_name: "read".into(),
571                    input_summary: "file.rs".into(),
572                    output_summary: Some("contents".into()),
573                    status: "success".into(),
574                },
575                ProcedureStep {
576                    tool_name: "edit".into(),
577                    input_summary: "change line 5".into(),
578                    output_summary: None,
579                    status: "success".into(),
580                },
581                ProcedureStep {
582                    tool_name: "bash".into(),
583                    input_summary: "cargo test".into(),
584                    output_summary: Some("ok".into()),
585                    status: "success".into(),
586                },
587            ],
588        };
589        let md = synthesize_skill_md(&candidate);
590        // YAML frontmatter delimiters
591        assert!(md.contains("---"), "expected YAML frontmatter delimiter");
592        assert!(md.contains("name: read-edit-bash"));
593        assert!(
594            md.contains("triggers:") && md.contains("  - \"read\""),
595            "expected YAML triggers list"
596        );
597        assert!(md.contains("## Steps"));
598        assert!(md.contains("1. **read**"));
599        assert!(md.contains("2. **edit**"));
600        assert!(md.contains("3. **bash**"));
601        assert!(md.contains("## When to Use"));
602    }
603
604    #[test]
605    fn write_learned_skill_creates_file() {
606        let dir = tempfile::tempdir().unwrap();
607        let candidate = CandidateProcedure {
608            name: "test-skill".into(),
609            description: "test".into(),
610            tool_sequence: vec!["a".into(), "b".into()],
611            success_ratio: 1.0,
612            steps: vec![],
613        };
614        let md = "# Test\n";
615        let path = write_learned_skill(dir.path(), &candidate, md).unwrap();
616        assert!(path.exists());
617        assert!(path.starts_with(dir.path().join("learned")));
618        let content = std::fs::read_to_string(&path).unwrap();
619        assert_eq!(content, md);
620    }
621
622    #[test]
623    fn sanitize_name_handles_special_chars() {
624        assert_eq!(sanitize_name("read/edit.bash"), "read-edit-bash");
625        assert_eq!(sanitize_name("Read-EDIT_Bash"), "read-edit_bash");
626    }
627
628    #[test]
629    fn learn_on_close_disabled_is_noop() {
630        let db = roboticus_db::Database::new(":memory:").unwrap();
631        let sid = roboticus_db::sessions::find_or_create(&db, "learn-agent", None).unwrap();
632        let session = roboticus_db::sessions::get_session(&db, &sid)
633            .unwrap()
634            .unwrap();
635        let dir = tempfile::tempdir().unwrap();
636
637        let config = LearningConfig {
638            enabled: false,
639            ..LearningConfig::default()
640        };
641        learn_on_close(&db, &config, &session, dir.path());
642
643        assert_eq!(
644            roboticus_db::learned_skills::count_learned_skills(&db).unwrap(),
645            0
646        );
647    }
648
649    #[test]
650    fn learn_on_close_with_tool_calls_creates_skill() {
651        let db = roboticus_db::Database::new(":memory:").unwrap();
652        let sid = roboticus_db::sessions::find_or_create(&db, "learn-agent", None).unwrap();
653        let session = roboticus_db::sessions::get_session(&db, &sid)
654            .unwrap()
655            .unwrap();
656
657        // Create a turn and tool calls
658        {
659            let conn = db.conn();
660            conn.execute(
661                "INSERT INTO turns (id, session_id) VALUES ('lt1', ?1)",
662                [&sid],
663            )
664            .unwrap();
665        }
666        roboticus_db::tools::record_tool_call(
667            &db,
668            "lt1",
669            "read",
670            r#"{"file":"a.rs"}"#,
671            Some("contents"),
672            "success",
673            Some(10),
674        )
675        .unwrap();
676        roboticus_db::tools::record_tool_call(
677            &db,
678            "lt1",
679            "edit",
680            r#"{"file":"a.rs"}"#,
681            Some("ok"),
682            "success",
683            Some(20),
684        )
685        .unwrap();
686        roboticus_db::tools::record_tool_call(
687            &db,
688            "lt1",
689            "bash",
690            r#"{"cmd":"cargo test"}"#,
691            Some("passed"),
692            "success",
693            Some(30),
694        )
695        .unwrap();
696
697        let dir = tempfile::tempdir().unwrap();
698        let config = LearningConfig::default();
699        learn_on_close(&db, &config, &session, dir.path());
700
701        // A learned skill should now exist
702        let count = roboticus_db::learned_skills::count_learned_skills(&db).unwrap();
703        assert!(count > 0, "should have learned at least one skill");
704
705        // A .md file should have been written
706        let learned_dir = dir.path().join("learned");
707        assert!(learned_dir.exists());
708        let files: Vec<_> = std::fs::read_dir(&learned_dir)
709            .unwrap()
710            .filter_map(|e| e.ok())
711            .collect();
712        assert!(!files.is_empty(), "should have written at least one .md");
713    }
714
715    #[test]
716    fn learn_on_close_respects_cap() {
717        let db = roboticus_db::Database::new(":memory:").unwrap();
718        let sid = roboticus_db::sessions::find_or_create(&db, "cap-agent", None).unwrap();
719        let session = roboticus_db::sessions::get_session(&db, &sid)
720            .unwrap()
721            .unwrap();
722
723        // Pre-fill to max
724        let config = LearningConfig {
725            max_learned_skills: 2,
726            ..LearningConfig::default()
727        };
728        roboticus_db::learned_skills::store_learned_skill(&db, "existing-a", "A", "[]", "[]", None)
729            .unwrap();
730        roboticus_db::learned_skills::store_learned_skill(&db, "existing-b", "B", "[]", "[]", None)
731            .unwrap();
732
733        let dir = tempfile::tempdir().unwrap();
734        learn_on_close(&db, &config, &session, dir.path());
735
736        // Should still be 2 — cap prevented new skills
737        assert_eq!(
738            roboticus_db::learned_skills::count_learned_skills(&db).unwrap(),
739            2
740        );
741    }
742
743    #[test]
744    fn learn_on_close_heals_null_skill_md_path() {
745        let db = roboticus_db::Database::new(":memory:").unwrap();
746        let sid = roboticus_db::sessions::find_or_create(&db, "heal-agent", None).unwrap();
747        let session = roboticus_db::sessions::get_session(&db, &sid)
748            .unwrap()
749            .unwrap();
750
751        // Pre-create a learned skill with NULL skill_md_path (simulates prior
752        // partial write where DB store succeeded but .md write failed).
753        roboticus_db::learned_skills::store_learned_skill(
754            &db,
755            "read-edit-bash",
756            "3-step procedure",
757            r#"["read","edit","bash"]"#,
758            "[]",
759            None,
760        )
761        .unwrap();
762
763        // Verify it starts with NULL path
764        let before = roboticus_db::learned_skills::get_learned_skill_by_name(&db, "read-edit-bash")
765            .unwrap()
766            .unwrap();
767        assert!(
768            before.skill_md_path.is_none(),
769            "precondition: path should be NULL"
770        );
771
772        // Create tool calls that will produce a candidate with the same name
773        {
774            let conn = db.conn();
775            conn.execute(
776                "INSERT INTO turns (id, session_id) VALUES ('ht1', ?1)",
777                [&sid],
778            )
779            .unwrap();
780        }
781        roboticus_db::tools::record_tool_call(
782            &db,
783            "ht1",
784            "read",
785            r#"{"file":"a.rs"}"#,
786            Some("contents"),
787            "success",
788            Some(10),
789        )
790        .unwrap();
791        roboticus_db::tools::record_tool_call(
792            &db,
793            "ht1",
794            "edit",
795            r#"{"file":"a.rs"}"#,
796            Some("ok"),
797            "success",
798            Some(20),
799        )
800        .unwrap();
801        roboticus_db::tools::record_tool_call(
802            &db,
803            "ht1",
804            "bash",
805            r#"{"cmd":"cargo test"}"#,
806            Some("passed"),
807            "success",
808            Some(30),
809        )
810        .unwrap();
811
812        let dir = tempfile::tempdir().unwrap();
813        let config = LearningConfig::default();
814        learn_on_close(&db, &config, &session, dir.path());
815
816        // After heal: skill_md_path should now be populated
817        let after = roboticus_db::learned_skills::get_learned_skill_by_name(&db, "read-edit-bash")
818            .unwrap()
819            .unwrap();
820        assert!(
821            after.skill_md_path.is_some(),
822            "skill_md_path should be healed to a real path"
823        );
824
825        // The .md file should actually exist on disk
826        let md_path = std::path::Path::new(after.skill_md_path.as_ref().unwrap());
827        assert!(md_path.exists(), "healed .md file should exist on disk");
828
829        // Success count should have been bumped (reinforced)
830        assert!(
831            after.success_count > before.success_count,
832            "success_count should have been incremented"
833        );
834    }
835}