Skip to main content

navi_core/
skill_mining.rs

1//! Trace-driven skill mining.
2
3use 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}