Skip to main content

clawft_core/agent/
skill_autogen.rs

1//! Autonomous skill creation (C4a).
2//!
3//! Detects repeated task patterns and auto-generates skill definitions.
4//! Generated skills are installed in `~/.clawft/skills/` in a "pending"
5//! state and require user approval before activation.
6//!
7//! **Disabled by default** -- must be opted into via configuration.
8//!
9//! # Security
10//!
11//! Auto-generated skills have minimal permissions:
12//! - No shell access
13//! - No network access
14//! - Filesystem limited to workspace directory
15//!
16//! # Pattern Detection
17//!
18//! The detector tracks sequences of tool calls. When the same sequence
19//! appears at least `threshold` times (default: 3), a skill candidate
20//! is generated.
21
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25use tracing::info;
26
27// ---------------------------------------------------------------------------
28// Configuration
29// ---------------------------------------------------------------------------
30
31/// Configuration for autonomous skill creation.
32#[derive(Debug, Clone)]
33pub struct AutogenConfig {
34    /// Whether autonomous skill creation is enabled.
35    /// Default: `false` (disabled).
36    pub enabled: bool,
37    /// Minimum number of pattern repetitions before suggesting a skill.
38    /// Default: 3.
39    pub threshold: usize,
40    /// Maximum number of pending skills allowed at once.
41    /// Prevents unbounded growth. Default: 10.
42    pub max_pending: usize,
43    /// Directory where generated skills are installed.
44    /// Default: `~/.clawft/skills/`
45    pub install_dir: Option<PathBuf>,
46}
47
48impl Default for AutogenConfig {
49    fn default() -> Self {
50        Self {
51            enabled: false,
52            threshold: 3,
53            max_pending: 10,
54            install_dir: None,
55        }
56    }
57}
58
59impl AutogenConfig {
60    /// Get the effective install directory, defaulting to `~/.clawft/skills/`.
61    pub fn install_dir(&self) -> PathBuf {
62        self.install_dir.clone().unwrap_or_else(|| {
63            #[cfg(feature = "native")]
64            {
65                dirs::home_dir()
66                    .unwrap_or_else(|| PathBuf::from("."))
67                    .join(".clawft")
68                    .join("skills")
69            }
70            #[cfg(not(feature = "native"))]
71            {
72                PathBuf::from(".clawft").join("skills")
73            }
74        })
75    }
76}
77
78// ---------------------------------------------------------------------------
79// Pattern Detection
80// ---------------------------------------------------------------------------
81
82/// A sequence of tool calls forming a pattern.
83#[derive(Debug, Clone, PartialEq, Eq, Hash)]
84pub struct ToolCallPattern {
85    /// Ordered list of tool names in the pattern.
86    pub tool_names: Vec<String>,
87}
88
89impl ToolCallPattern {
90    /// Create a new pattern from tool names.
91    pub fn new(tool_names: Vec<String>) -> Self {
92        Self { tool_names }
93    }
94
95    /// Generate a suggested skill name from the pattern.
96    pub fn suggested_name(&self) -> String {
97        if self.tool_names.is_empty() {
98            return "unnamed-skill".to_string();
99        }
100        let parts: Vec<&str> = self.tool_names.iter().map(|s| s.as_str()).collect();
101        let name = parts.join("-then-");
102        // Truncate to reasonable length
103        if name.len() > 60 {
104            format!("{}-auto", &name[..56])
105        } else {
106            format!("{name}-auto")
107        }
108    }
109
110    /// Number of steps in the pattern.
111    pub fn len(&self) -> usize {
112        self.tool_names.len()
113    }
114
115    /// Whether the pattern is empty.
116    pub fn is_empty(&self) -> bool {
117        self.tool_names.is_empty()
118    }
119}
120
121/// Detects repeated tool call patterns in the agent's execution history.
122pub struct PatternDetector {
123    /// Configuration for pattern detection.
124    config: AutogenConfig,
125    /// Counts of each observed pattern.
126    pattern_counts: HashMap<ToolCallPattern, usize>,
127    /// Recent tool calls (sliding window).
128    recent_calls: Vec<String>,
129    /// Maximum window size for pattern detection.
130    max_window: usize,
131    /// Patterns that have already been reported (to avoid duplicates).
132    reported: HashMap<ToolCallPattern, bool>,
133}
134
135impl PatternDetector {
136    /// Create a new pattern detector.
137    pub fn new(config: AutogenConfig) -> Self {
138        Self {
139            config,
140            pattern_counts: HashMap::new(),
141            recent_calls: Vec::new(),
142            max_window: 10,
143            reported: HashMap::new(),
144        }
145    }
146
147    /// Record a tool call.
148    pub fn record_tool_call(&mut self, tool_name: &str) {
149        if !self.config.enabled {
150            return;
151        }
152
153        self.recent_calls.push(tool_name.to_string());
154
155        // Keep window bounded
156        if self.recent_calls.len() > self.max_window * 3 {
157            self.recent_calls.drain(..self.max_window);
158        }
159
160        // Extract patterns of length 2..=max_window from recent calls
161        let n = self.recent_calls.len();
162        for len in 2..=self.max_window.min(n) {
163            let pattern = ToolCallPattern::new(
164                self.recent_calls[n - len..n].to_vec(),
165            );
166            *self.pattern_counts.entry(pattern).or_insert(0) += 1;
167        }
168    }
169
170    /// Check for patterns that have reached the threshold.
171    ///
172    /// Returns newly detected patterns (patterns that just crossed the
173    /// threshold and have not been reported before).
174    pub fn detect_candidates(&mut self) -> Vec<ToolCallPattern> {
175        if !self.config.enabled {
176            return vec![];
177        }
178
179        let threshold = self.config.threshold;
180        let mut candidates = Vec::new();
181
182        for (pattern, count) in &self.pattern_counts {
183            if *count >= threshold && !self.reported.contains_key(pattern) {
184                candidates.push(pattern.clone());
185            }
186        }
187
188        // Mark as reported
189        for candidate in &candidates {
190            self.reported.insert(candidate.clone(), true);
191        }
192
193        candidates
194    }
195
196    /// Get the count for a specific pattern.
197    pub fn pattern_count(&self, pattern: &ToolCallPattern) -> usize {
198        self.pattern_counts.get(pattern).copied().unwrap_or(0)
199    }
200
201    /// Reset all pattern tracking state.
202    pub fn reset(&mut self) {
203        self.pattern_counts.clear();
204        self.recent_calls.clear();
205        self.reported.clear();
206    }
207
208    /// Whether the detector is enabled.
209    pub fn is_enabled(&self) -> bool {
210        self.config.enabled
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Skill Generation
216// ---------------------------------------------------------------------------
217
218/// A generated skill candidate awaiting user approval.
219#[derive(Debug, Clone)]
220pub struct SkillCandidate {
221    /// Suggested skill name.
222    pub name: String,
223    /// Description of what the skill does.
224    pub description: String,
225    /// The tool call pattern that triggered generation.
226    pub pattern: ToolCallPattern,
227    /// Generated SKILL.md content.
228    pub skill_md: String,
229    /// Approval state.
230    pub state: CandidateState,
231}
232
233/// State of a skill candidate in the approval pipeline.
234#[non_exhaustive]
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum CandidateState {
237    /// Awaiting user review and approval.
238    Pending,
239    /// Approved by user, ready for installation.
240    Approved,
241    /// Rejected by user.
242    Rejected,
243}
244
245/// Generates SKILL.md content from a detected pattern.
246pub fn generate_skill_md(pattern: &ToolCallPattern) -> SkillCandidate {
247    let name = pattern.suggested_name();
248    let tools_list = pattern
249        .tool_names
250        .iter()
251        .map(|t| format!("  - {t}"))
252        .collect::<Vec<_>>()
253        .join("\n");
254
255    let description = format!(
256        "Auto-generated skill: executes {} in sequence",
257        pattern
258            .tool_names
259            .iter()
260            .map(|t| format!("`{t}`"))
261            .collect::<Vec<_>>()
262            .join(" -> ")
263    );
264
265    let instructions = format!(
266        "Execute the following tools in order:\n{}",
267        pattern
268            .tool_names
269            .iter()
270            .enumerate()
271            .map(|(i, t)| format!("{}. Use the `{t}` tool", i + 1))
272            .collect::<Vec<_>>()
273            .join("\n")
274    );
275
276    let skill_md = format!(
277        "---\n\
278         name: {name}\n\
279         description: \"{description}\"\n\
280         version: 0.1.0\n\
281         allowed-tools:\n\
282         {tools_list}\n\
283         user-invocable: false\n\
284         autogenerated: true\n\
285         ---\n\n\
286         {instructions}\n"
287    );
288
289    SkillCandidate {
290        name,
291        description,
292        pattern: pattern.clone(),
293        skill_md,
294        state: CandidateState::Pending,
295    }
296}
297
298/// Install a skill candidate to the managed skills directory.
299///
300/// Creates a directory `{install_dir}/{name}/` with a `SKILL.md` file.
301/// The skill is installed in "pending" state -- it will not be loaded
302/// by the skill watcher until approved.
303pub fn install_pending_skill(
304    candidate: &SkillCandidate,
305    install_dir: &Path,
306) -> Result<PathBuf, String> {
307    let skill_dir = install_dir.join(&candidate.name);
308
309    std::fs::create_dir_all(&skill_dir)
310        .map_err(|e| format!("create skill dir: {e}"))?;
311
312    let skill_path = skill_dir.join("SKILL.md");
313    std::fs::write(&skill_path, &candidate.skill_md)
314        .map_err(|e| format!("write SKILL.md: {e}"))?;
315
316    // Write a .pending marker file -- the skill watcher should
317    // skip loading skills with this marker until approved.
318    let marker_path = skill_dir.join(".pending");
319    std::fs::write(&marker_path, "awaiting user approval")
320        .map_err(|e| format!("write .pending marker: {e}"))?;
321
322    info!(
323        skill = %candidate.name,
324        path = %skill_dir.display(),
325        "installed pending skill"
326    );
327
328    Ok(skill_dir)
329}
330
331/// Approve a pending skill by removing the `.pending` marker.
332pub fn approve_skill(skill_dir: &Path) -> Result<(), String> {
333    let marker = skill_dir.join(".pending");
334    if marker.exists() {
335        std::fs::remove_file(&marker).map_err(|e| format!("remove .pending: {e}"))?;
336        info!(skill_dir = %skill_dir.display(), "approved pending skill");
337        Ok(())
338    } else {
339        Err("skill is not in pending state".into())
340    }
341}
342
343/// Reject a pending skill by removing its directory.
344pub fn reject_skill(skill_dir: &Path) -> Result<(), String> {
345    if skill_dir.exists() {
346        std::fs::remove_dir_all(skill_dir)
347            .map_err(|e| format!("remove skill dir: {e}"))?;
348        info!(skill_dir = %skill_dir.display(), "rejected and removed pending skill");
349        Ok(())
350    } else {
351        Err("skill directory does not exist".into())
352    }
353}
354
355/// Check if a skill directory is in pending state.
356pub fn is_pending(skill_dir: &Path) -> bool {
357    skill_dir.join(".pending").exists()
358}
359
360// ---------------------------------------------------------------------------
361// Tests
362// ---------------------------------------------------------------------------
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn enabled_config() -> AutogenConfig {
369        AutogenConfig {
370            enabled: true,
371            threshold: 3,
372            max_pending: 10,
373            install_dir: None,
374        }
375    }
376
377    fn disabled_config() -> AutogenConfig {
378        AutogenConfig::default()
379    }
380
381    // -- AutogenConfig tests --
382
383    #[test]
384    fn config_default_is_disabled() {
385        let config = AutogenConfig::default();
386        assert!(!config.enabled);
387        assert_eq!(config.threshold, 3);
388        assert_eq!(config.max_pending, 10);
389    }
390
391    #[test]
392    fn config_install_dir_default() {
393        let config = AutogenConfig::default();
394        let dir = config.install_dir();
395        assert!(dir.to_string_lossy().contains(".clawft"));
396        assert!(dir.to_string_lossy().contains("skills"));
397    }
398
399    // -- ToolCallPattern tests --
400
401    #[test]
402    fn pattern_suggested_name() {
403        let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
404        let name = pattern.suggested_name();
405        assert!(name.contains("read_file"));
406        assert!(name.contains("edit_file"));
407        assert!(name.ends_with("-auto"));
408    }
409
410    #[test]
411    fn pattern_suggested_name_long_truncated() {
412        let tools: Vec<String> = (0..20).map(|i| format!("tool_{i}")).collect();
413        let pattern = ToolCallPattern::new(tools);
414        let name = pattern.suggested_name();
415        assert!(name.len() <= 65, "name too long: {}", name.len());
416    }
417
418    #[test]
419    fn pattern_empty() {
420        let pattern = ToolCallPattern::new(vec![]);
421        assert!(pattern.is_empty());
422        assert_eq!(pattern.len(), 0);
423        assert_eq!(pattern.suggested_name(), "unnamed-skill");
424    }
425
426    // -- PatternDetector tests --
427
428    #[test]
429    fn detector_disabled_records_nothing() {
430        let mut detector = PatternDetector::new(disabled_config());
431        detector.record_tool_call("read_file");
432        detector.record_tool_call("edit_file");
433        let candidates = detector.detect_candidates();
434        assert!(candidates.is_empty());
435    }
436
437    #[test]
438    fn detector_does_not_fire_below_threshold() {
439        let mut detector = PatternDetector::new(enabled_config());
440        // Record pattern twice (below threshold of 3)
441        for _ in 0..2 {
442            detector.record_tool_call("read_file");
443            detector.record_tool_call("edit_file");
444        }
445        let candidates = detector.detect_candidates();
446        assert!(candidates.is_empty());
447    }
448
449    #[test]
450    fn detector_fires_at_threshold() {
451        let mut detector = PatternDetector::new(enabled_config());
452        // Record pattern 3 times (at threshold)
453        for _ in 0..3 {
454            detector.record_tool_call("read_file");
455            detector.record_tool_call("edit_file");
456        }
457        let candidates = detector.detect_candidates();
458        assert!(
459            !candidates.is_empty(),
460            "should detect at least one pattern"
461        );
462    }
463
464    #[test]
465    fn detector_does_not_re_report() {
466        let mut detector = PatternDetector::new(enabled_config());
467        for _ in 0..5 {
468            detector.record_tool_call("read_file");
469            detector.record_tool_call("edit_file");
470        }
471        let first = detector.detect_candidates();
472        assert!(!first.is_empty());
473
474        // Second call should not re-report the same pattern
475        let second = detector.detect_candidates();
476        assert!(second.is_empty(), "should not re-report same pattern");
477    }
478
479    #[test]
480    fn detector_reset_clears_state() {
481        let mut detector = PatternDetector::new(enabled_config());
482        for _ in 0..3 {
483            detector.record_tool_call("a");
484            detector.record_tool_call("b");
485        }
486        let _ = detector.detect_candidates();
487        detector.reset();
488
489        // After reset, same pattern should be detected again
490        for _ in 0..3 {
491            detector.record_tool_call("a");
492            detector.record_tool_call("b");
493        }
494        let candidates = detector.detect_candidates();
495        assert!(!candidates.is_empty());
496    }
497
498    #[test]
499    fn detector_configurable_threshold() {
500        let config = AutogenConfig {
501            enabled: true,
502            threshold: 5,
503            ..Default::default()
504        };
505        let mut detector = PatternDetector::new(config);
506
507        for _ in 0..4 {
508            detector.record_tool_call("x");
509            detector.record_tool_call("y");
510        }
511        assert!(detector.detect_candidates().is_empty(), "below threshold");
512
513        detector.record_tool_call("x");
514        detector.record_tool_call("y");
515        assert!(!detector.detect_candidates().is_empty(), "at threshold");
516    }
517
518    // -- Skill generation tests --
519
520    #[test]
521    fn generate_skill_md_valid() {
522        let pattern = ToolCallPattern::new(vec![
523            "read_file".into(),
524            "edit_file".into(),
525            "write_file".into(),
526        ]);
527        let candidate = generate_skill_md(&pattern);
528
529        assert!(candidate.name.contains("auto"));
530        assert_eq!(candidate.state, CandidateState::Pending);
531        assert!(candidate.skill_md.contains("---"));
532        assert!(candidate.skill_md.contains("read_file"));
533        assert!(candidate.skill_md.contains("edit_file"));
534        assert!(candidate.skill_md.contains("write_file"));
535        assert!(candidate.skill_md.contains("autogenerated: true"));
536        assert!(candidate.skill_md.contains("user-invocable: false"));
537    }
538
539    #[test]
540    fn generate_skill_md_description_contains_tools() {
541        let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
542        let candidate = generate_skill_md(&pattern);
543        assert!(candidate.description.contains("`a`"));
544        assert!(candidate.description.contains("`b`"));
545    }
546
547    // -- Install/approve/reject tests --
548
549    #[test]
550    fn install_pending_skill_creates_files() {
551        let dir = std::env::temp_dir().join("clawft_autogen_install_test");
552        let _ = std::fs::remove_dir_all(&dir);
553        let _ = std::fs::create_dir_all(&dir);
554
555        let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
556        let candidate = generate_skill_md(&pattern);
557
558        let result = install_pending_skill(&candidate, &dir);
559        assert!(result.is_ok());
560
561        let skill_dir = result.unwrap();
562        assert!(skill_dir.join("SKILL.md").exists());
563        assert!(skill_dir.join(".pending").exists());
564        assert!(is_pending(&skill_dir));
565
566        let _ = std::fs::remove_dir_all(&dir);
567    }
568
569    #[test]
570    fn approve_skill_removes_marker() {
571        let dir = std::env::temp_dir().join("clawft_autogen_approve_test");
572        let _ = std::fs::remove_dir_all(&dir);
573        let _ = std::fs::create_dir_all(&dir);
574
575        let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
576        let candidate = generate_skill_md(&pattern);
577        let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
578
579        assert!(is_pending(&skill_dir));
580        assert!(approve_skill(&skill_dir).is_ok());
581        assert!(!is_pending(&skill_dir));
582        assert!(skill_dir.join("SKILL.md").exists());
583
584        let _ = std::fs::remove_dir_all(&dir);
585    }
586
587    #[test]
588    fn reject_skill_removes_directory() {
589        let dir = std::env::temp_dir().join("clawft_autogen_reject_test");
590        let _ = std::fs::remove_dir_all(&dir);
591        let _ = std::fs::create_dir_all(&dir);
592
593        let pattern = ToolCallPattern::new(vec!["x".into(), "y".into()]);
594        let candidate = generate_skill_md(&pattern);
595        let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
596
597        assert!(skill_dir.exists());
598        assert!(reject_skill(&skill_dir).is_ok());
599        assert!(!skill_dir.exists());
600
601        let _ = std::fs::remove_dir_all(&dir);
602    }
603
604    #[test]
605    fn approve_non_pending_fails() {
606        let dir = std::env::temp_dir().join("clawft_autogen_approve_nopend");
607        let _ = std::fs::remove_dir_all(&dir);
608        let _ = std::fs::create_dir_all(&dir);
609
610        // No .pending marker
611        let result = approve_skill(&dir);
612        assert!(result.is_err());
613        assert!(result.unwrap_err().contains("not in pending state"));
614
615        let _ = std::fs::remove_dir_all(&dir);
616    }
617
618    #[test]
619    fn generated_skill_has_minimal_permissions() {
620        let pattern = ToolCallPattern::new(vec!["read_file".into()]);
621        let candidate = generate_skill_md(&pattern);
622
623        // Generated skill should NOT have shell, network, or broad FS access
624        assert!(!candidate.skill_md.contains("shell: true"));
625        assert!(!candidate.skill_md.contains("network:"));
626        // user-invocable should be false for safety
627        assert!(candidate.skill_md.contains("user-invocable: false"));
628    }
629
630    #[test]
631    fn is_enabled_reflects_config() {
632        let enabled = PatternDetector::new(enabled_config());
633        assert!(enabled.is_enabled());
634
635        let disabled = PatternDetector::new(disabled_config());
636        assert!(!disabled.is_enabled());
637    }
638}