navi_core/
skill_mining.rs1use crate::trace::{TurnOutcome, TurnTrace};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct SkillDraft {
8 pub name: String,
9 pub trigger: String,
10 pub workflow: Vec<String>,
11 pub required_tools: Vec<String>,
12 pub required_capabilities: Vec<String>,
13 pub verification_steps: Vec<String>,
14 pub examples: Vec<String>,
15 pub activated: bool,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct SkillReplayReport {
20 pub passed: bool,
21 pub replay_pass_rate: f64,
22 pub reason: String,
23}
24
25pub fn draft_skill_from_traces(traces: &[TurnTrace], min_repetitions: usize) -> Option<SkillDraft> {
26 if traces.len() < min_repetitions {
27 return None;
28 }
29 let successful = traces
30 .iter()
31 .filter(|trace| {
32 matches!(
33 trace.outcome,
34 TurnOutcome::Success | TurnOutcome::PartialSuccess
35 )
36 })
37 .collect::<Vec<_>>();
38 if successful.len() < min_repetitions {
39 return None;
40 }
41 let first_tools = successful
42 .first()?
43 .tool_calls
44 .iter()
45 .map(|call| call.invocation.tool_name.clone())
46 .collect::<Vec<_>>();
47 if first_tools.is_empty() {
48 return None;
49 }
50 let repeated = successful.iter().all(|trace| {
51 trace
52 .tool_calls
53 .iter()
54 .map(|call| call.invocation.tool_name.clone())
55 .collect::<Vec<_>>()
56 == first_tools
57 });
58 if !repeated {
59 return None;
60 }
61
62 let verification_steps = successful
63 .iter()
64 .flat_map(|trace| {
65 trace
66 .verifier_results
67 .iter()
68 .filter(|verifier| verifier.passed)
69 })
70 .map(|verifier| verifier.command.clone())
71 .collect::<std::collections::BTreeSet<_>>()
72 .into_iter()
73 .collect::<Vec<_>>();
74 if verification_steps.is_empty() {
75 return None;
76 }
77 let required_capabilities = successful
78 .iter()
79 .flat_map(|trace| trace.capabilities.iter())
80 .map(|entry| entry.capability.as_key())
81 .collect::<std::collections::BTreeSet<_>>()
82 .into_iter()
83 .collect::<Vec<_>>();
84
85 Some(SkillDraft {
86 name: sanitize_name(&successful[0].task),
87 trigger: successful[0].task.clone(),
88 workflow: first_tools.clone(),
89 required_tools: first_tools,
90 required_capabilities,
91 verification_steps,
92 examples: successful.iter().map(|trace| trace.task.clone()).collect(),
93 activated: false,
94 })
95}
96
97pub fn activate_skill_after_replay(
98 mut draft: SkillDraft,
99 report: &SkillReplayReport,
100 threshold: f64,
101) -> SkillDraft {
102 draft.activated = report.passed && report.replay_pass_rate >= threshold;
103 draft
104}
105
106fn sanitize_name(value: &str) -> String {
107 let mut out = String::new();
108 for ch in value.chars() {
109 if ch.is_ascii_alphanumeric() {
110 out.push(ch.to_ascii_lowercase());
111 } else if !out.ends_with('-') {
112 out.push('-');
113 }
114 }
115 out.trim_matches('-').chars().take(64).collect()
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::tool::{ToolInvocation, ToolResult};
122
123 fn trace(id: &str) -> TurnTrace {
124 let mut trace = TurnTrace::new(id, "s", "p", "m", "Fix repeated task");
125 trace.record_capability(crate::capability::CapabilityLedgerEntry {
126 capability: crate::capability::Capability::RepoRead,
127 scope: crate::capability::CapabilityScope::Turn(id.to_string()),
128 decision: crate::capability::CapabilityDecision::Consumed,
129 at_ms: 1,
130 justification: "read source".to_string(),
131 });
132 trace.record_tool_call(
133 &ToolInvocation {
134 id: "read".to_string(),
135 tool_name: "read_file".to_string(),
136 input: serde_json::json!({"path": "src/lib.rs"}),
137 },
138 &ToolResult {
139 invocation_id: "read".to_string(),
140 ok: true,
141 output: serde_json::json!({}),
142 },
143 1,
144 );
145 trace.record_verifier("test", "just test-crate navi-core", true, 1, Some(0));
146 trace
147 }
148
149 #[test]
150 fn repeated_successful_traces_create_skill_draft() {
151 let draft = draft_skill_from_traces(&[trace("a"), trace("b")], 2).unwrap();
152
153 assert_eq!(draft.required_tools, vec!["read_file"]);
154 assert_eq!(draft.required_capabilities, vec!["repo.read"]);
155 assert!(!draft.activated);
156 }
157
158 #[test]
159 fn bad_replay_does_not_activate_skill() {
160 let draft = draft_skill_from_traces(&[trace("a"), trace("b")], 2).unwrap();
161 let activated = activate_skill_after_replay(
162 draft,
163 &SkillReplayReport {
164 passed: false,
165 replay_pass_rate: 0.0,
166 reason: "failed".to_string(),
167 },
168 0.8,
169 );
170
171 assert!(!activated.activated);
172 }
173}