1use 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#[derive(Debug, Clone)]
30pub struct CandidateProcedure {
31 pub name: String,
33 pub description: String,
35 pub tool_sequence: Vec<String>,
37 pub success_ratio: f64,
39 pub steps: Vec<ProcedureStep>,
41}
42
43#[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
52pub 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 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 let mut candidates: Vec<CandidateProcedure> = Vec::new();
75
76 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 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 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
139pub 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 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 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 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 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
210pub 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 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 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
240pub 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 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 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 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 match roboticus_db::learned_skills::get_learned_skill_by_name(db, &candidate.name) {
305 Ok(Some(existing)) => {
306 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 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 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 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 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
400fn truncate(s: &str, max_len: usize) -> String {
403 if s.len() <= max_len {
404 s.to_string()
405 } else {
406 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 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
450fn 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#[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 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 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 {
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 let count = roboticus_db::learned_skills::count_learned_skills(&db).unwrap();
703 assert!(count > 0, "should have learned at least one skill");
704
705 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 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 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 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 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 {
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 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 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 assert!(
831 after.success_count > before.success_count,
832 "success_count should have been incremented"
833 );
834 }
835}