Skip to main content

zeroclaw_runtime/skills/
improver.rs

1// Skill self-improvement: atomically updates existing skill documents
2// after the agent uses them successfully.
3//
4// in `src/skills/mod.rs`.
5
6use anyhow::{Context, Result, bail};
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::time::Instant;
10use zeroclaw_config::schema::SkillImprovementConfig;
11
12/// Manages skill self-improvement with cooldown tracking.
13pub struct SkillImprover {
14    workspace_dir: PathBuf,
15    config: SkillImprovementConfig,
16    cooldowns: HashMap<String, Instant>,
17}
18
19impl SkillImprover {
20    pub fn new(workspace_dir: PathBuf, config: SkillImprovementConfig) -> Self {
21        Self {
22            workspace_dir,
23            config,
24            cooldowns: HashMap::new(),
25        }
26    }
27
28    /// Check whether a skill is eligible for improvement (enabled + cooldown expired).
29    pub fn should_improve_skill(&self, slug: &str) -> bool {
30        if !self.config.enabled {
31            return false;
32        }
33        if let Some(last) = self.cooldowns.get(slug) {
34            let elapsed = Instant::now().saturating_duration_since(*last);
35            elapsed.as_secs() >= self.config.cooldown_secs
36        } else {
37            true
38        }
39    }
40
41    /// Improve an existing skill file atomically.
42    ///
43    /// Writes to a temp file first, validates, then renames over the original.
44    /// Returns `Ok(Some(slug))` if the skill was improved, `Ok(None)` if skipped
45    /// (disabled, cooldown active, or validation failed).
46    pub async fn improve_skill(
47        &mut self,
48        slug: &str,
49        improved_content: &str,
50        improvement_reason: &str,
51    ) -> Result<Option<String>> {
52        if !self.should_improve_skill(slug) {
53            return Ok(None);
54        }
55
56        // Validate the improved content before writing.
57        validate_skill_content(improved_content)?;
58
59        let skill_dir = self.skills_dir().join(slug);
60        let toml_path = skill_dir.join("SKILL.toml");
61
62        if !toml_path.exists() {
63            bail!("Skill file not found: {}", toml_path.display().to_string());
64        }
65
66        // Read existing content to preserve audit trail.
67        let existing = tokio::fs::read_to_string(&toml_path)
68            .await
69            .with_context(|| format!("Failed to read {}", toml_path.display().to_string()))?;
70
71        // Build the updated content with audit metadata appended.
72        let now = chrono::Utc::now().to_rfc3339();
73        let audit_entry = format!(
74            "\n# Improvement: {now}\n# Reason: {}\n",
75            improvement_reason.replace('\n', " ")
76        );
77
78        let updated = append_improvement_metadata(improved_content, &now, improvement_reason);
79
80        // Preserve any existing audit trail from the original file.
81        let audit_trail = extract_audit_trail(&existing);
82        let final_content = if audit_trail.is_empty() {
83            format!("{updated}{audit_entry}")
84        } else {
85            format!("{updated}\n{audit_trail}{audit_entry}")
86        };
87
88        // Atomic write: temp file → validate → rename.
89        let temp_path = skill_dir.join(".SKILL.toml.tmp");
90        tokio::fs::write(&temp_path, final_content.as_bytes())
91            .await
92            .with_context(|| {
93                format!(
94                    "Failed to write temp file: {}",
95                    temp_path.display().to_string()
96                )
97            })?;
98
99        // Validate the temp file is readable and valid.
100        let written = tokio::fs::read_to_string(&temp_path).await?;
101        if let Err(e) = validate_skill_content(&written) {
102            // Clean up temp file and abort.
103            let _ = tokio::fs::remove_file(&temp_path).await;
104            bail!("Validation failed after write: {e}");
105        }
106
107        // Rename atomically (same filesystem).
108        tokio::fs::rename(&temp_path, &toml_path)
109            .await
110            .with_context(|| {
111                format!(
112                    "Failed to rename {} to {}",
113                    temp_path.display().to_string(),
114                    toml_path.display()
115                )
116            })?;
117
118        // Record cooldown.
119        self.cooldowns.insert(slug.to_string(), Instant::now());
120
121        Ok(Some(slug.to_string()))
122    }
123
124    fn skills_dir(&self) -> PathBuf {
125        self.workspace_dir.join("skills")
126    }
127}
128
129/// Validate skill content: must be non-empty, valid UTF-8 (already a &str),
130/// and contain parseable TOML front-matter with a `[skill]` section.
131pub fn validate_skill_content(content: &str) -> Result<()> {
132    if content.trim().is_empty() {
133        bail!("Skill content is empty");
134    }
135
136    // Must contain a [skill] section.
137    #[derive(serde::Deserialize)]
138    struct Partial {
139        skill: PartialSkill,
140    }
141    #[derive(serde::Deserialize)]
142    struct PartialSkill {
143        name: Option<String>,
144    }
145
146    // Try parsing as TOML. Strip trailing comment lines that aren't valid TOML.
147    let toml_portion = strip_trailing_comments(content);
148    let parsed: Partial = toml::from_str(&toml_portion)
149        .with_context(|| "Skill content contains malformed TOML front-matter")?;
150
151    if parsed.skill.name.as_deref().unwrap_or("").is_empty() {
152        bail!("Skill TOML missing required 'name' field");
153    }
154
155    Ok(())
156}
157
158/// Append updated_at and improvement_reason to the [skill] section's front-matter.
159fn append_improvement_metadata(content: &str, timestamp: &str, reason: &str) -> String {
160    // Find the end of the [skill] section (before the first [[tools]] or end of file).
161    let tools_pos = content.find("[[tools]]");
162    let (skill_section, rest) = match tools_pos {
163        Some(pos) => (&content[..pos], &content[pos..]),
164        None => (content, ""),
165    };
166
167    // Check if updated_at already exists; if so, replace it.
168    let skill_section = if skill_section.contains("updated_at") {
169        let mut lines: Vec<&str> = skill_section.lines().collect();
170        lines.retain(|line| !line.trim_start().starts_with("updated_at"));
171        lines.join("\n") + "\n"
172    } else {
173        skill_section.to_string()
174    };
175
176    let escaped_reason = reason.replace('"', "\\\"").replace('\n', " ");
177    format!(
178        "{skill_section}updated_at = \"{timestamp}\"\nimprovement_reason = \"{escaped_reason}\"\n{rest}"
179    )
180}
181
182/// Extract existing audit trail comments (lines starting with `# Improvement:` or `# Reason:`).
183fn extract_audit_trail(content: &str) -> String {
184    content
185        .lines()
186        .filter(|line| {
187            let trimmed = line.trim();
188            trimmed.starts_with("# Improvement:") || trimmed.starts_with("# Reason:")
189        })
190        .collect::<Vec<_>>()
191        .join("\n")
192}
193
194/// Strip trailing comment-only lines that would break TOML parsing.
195fn strip_trailing_comments(content: &str) -> String {
196    let lines: Vec<&str> = content.lines().collect();
197    let mut end = lines.len();
198    while end > 0 {
199        let line = lines[end - 1].trim();
200        if line.is_empty() || line.starts_with('#') {
201            end -= 1;
202        } else {
203            break;
204        }
205    }
206    lines[..end].join("\n")
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    // ── Validation ──────────────────────────────────────────
214
215    #[test]
216    fn validate_empty_content_rejected() {
217        assert!(validate_skill_content("").is_err());
218        assert!(validate_skill_content("   \n  ").is_err());
219    }
220
221    #[test]
222    fn validate_malformed_toml_rejected() {
223        assert!(validate_skill_content("not valid toml {{").is_err());
224    }
225
226    #[test]
227    fn validate_missing_name_rejected() {
228        let content = r#"
229[skill]
230description = "no name field"
231version = "0.1.0"
232"#;
233        assert!(validate_skill_content(content).is_err());
234    }
235
236    #[test]
237    fn validate_valid_content_accepted() {
238        let content = r#"
239[skill]
240name = "test-skill"
241description = "A test skill"
242version = "0.1.0"
243"#;
244        assert!(validate_skill_content(content).is_ok());
245    }
246
247    // ── Cooldown enforcement ────────────────────────────────
248
249    #[test]
250    fn cooldown_allows_first_improvement() {
251        let improver = SkillImprover::new(
252            PathBuf::from("/tmp/test"),
253            SkillImprovementConfig {
254                enabled: true,
255                cooldown_secs: 3600,
256            },
257        );
258        assert!(improver.should_improve_skill("test-skill"));
259    }
260
261    #[test]
262    fn cooldown_blocks_recent_improvement() {
263        let mut improver = SkillImprover::new(
264            PathBuf::from("/tmp/test"),
265            SkillImprovementConfig {
266                enabled: true,
267                cooldown_secs: 3600,
268            },
269        );
270        improver
271            .cooldowns
272            .insert("test-skill".to_string(), Instant::now());
273        assert!(!improver.should_improve_skill("test-skill"));
274    }
275
276    #[test]
277    fn cooldown_disabled_blocks_all() {
278        let improver = SkillImprover::new(
279            PathBuf::from("/tmp/test"),
280            SkillImprovementConfig {
281                enabled: false,
282                cooldown_secs: 0,
283            },
284        );
285        assert!(!improver.should_improve_skill("test-skill"));
286    }
287
288    // ── Atomic write ────────────────────────────────────────
289
290    #[tokio::test]
291    async fn improve_skill_atomic_write() {
292        let dir = tempfile::tempdir().unwrap();
293        let skill_dir = dir.path().join("skills").join("test-skill");
294        tokio::fs::create_dir_all(&skill_dir).await.unwrap();
295
296        let original = r#"[skill]
297name = "test-skill"
298description = "Original description"
299version = "0.1.0"
300author = "zeroclaw-auto"
301tags = ["auto-generated"]
302"#;
303        tokio::fs::write(skill_dir.join("SKILL.toml"), original)
304            .await
305            .unwrap();
306
307        let mut improver = SkillImprover::new(
308            dir.path().to_path_buf(),
309            SkillImprovementConfig {
310                enabled: true,
311                cooldown_secs: 0,
312            },
313        );
314
315        let improved = r#"[skill]
316name = "test-skill"
317description = "Improved description with better steps"
318version = "0.1.1"
319author = "zeroclaw-auto"
320tags = ["auto-generated", "improved"]
321"#;
322
323        let result = improver
324            .improve_skill("test-skill", improved, "Added better step descriptions")
325            .await
326            .unwrap();
327        assert_eq!(result, Some("test-skill".to_string()));
328
329        // Verify the file was updated.
330        let content = tokio::fs::read_to_string(skill_dir.join("SKILL.toml"))
331            .await
332            .unwrap();
333        assert!(content.contains("Improved description"));
334        assert!(content.contains("updated_at"));
335        assert!(content.contains("improvement_reason"));
336
337        // Verify temp file was cleaned up.
338        assert!(!skill_dir.join(".SKILL.toml.tmp").exists());
339    }
340
341    #[tokio::test]
342    async fn improve_skill_invalid_content_aborts() {
343        let dir = tempfile::tempdir().unwrap();
344        let skill_dir = dir.path().join("skills").join("test-skill");
345        tokio::fs::create_dir_all(&skill_dir).await.unwrap();
346
347        let original = r#"[skill]
348name = "test-skill"
349description = "Original"
350version = "0.1.0"
351"#;
352        tokio::fs::write(skill_dir.join("SKILL.toml"), original)
353            .await
354            .unwrap();
355
356        let mut improver = SkillImprover::new(
357            dir.path().to_path_buf(),
358            SkillImprovementConfig {
359                enabled: true,
360                cooldown_secs: 0,
361            },
362        );
363
364        // Empty content should fail validation.
365        let result = improver
366            .improve_skill("test-skill", "", "bad improvement")
367            .await;
368        assert!(result.is_err());
369
370        // Original file should be untouched.
371        let content = tokio::fs::read_to_string(skill_dir.join("SKILL.toml"))
372            .await
373            .unwrap();
374        assert!(content.contains("Original"));
375    }
376
377    #[tokio::test]
378    async fn improve_skill_cooldown_returns_none() {
379        let dir = tempfile::tempdir().unwrap();
380        let skill_dir = dir.path().join("skills").join("test-skill");
381        tokio::fs::create_dir_all(&skill_dir).await.unwrap();
382        tokio::fs::write(
383            skill_dir.join("SKILL.toml"),
384            "[skill]\nname = \"test-skill\"\n",
385        )
386        .await
387        .unwrap();
388
389        let mut improver = SkillImprover::new(
390            dir.path().to_path_buf(),
391            SkillImprovementConfig {
392                enabled: true,
393                cooldown_secs: 9999,
394            },
395        );
396        // Record a recent cooldown.
397        improver
398            .cooldowns
399            .insert("test-skill".to_string(), Instant::now());
400
401        let result = improver
402            .improve_skill(
403                "test-skill",
404                "[skill]\nname = \"test-skill\"\ndescription = \"better\"\n",
405                "test",
406            )
407            .await
408            .unwrap();
409        assert!(result.is_none());
410    }
411
412    // ── Metadata appending ──────────────────────────────────
413
414    #[test]
415    fn append_metadata_adds_fields() {
416        let content = r#"[skill]
417name = "test"
418description = "A skill"
419version = "0.1.0"
420"#;
421        let result = append_improvement_metadata(content, "2026-01-01T00:00:00Z", "Better steps");
422        assert!(result.contains("updated_at = \"2026-01-01T00:00:00Z\""));
423        assert!(result.contains("improvement_reason = \"Better steps\""));
424    }
425
426    #[test]
427    fn append_metadata_preserves_tools() {
428        let content = r#"[skill]
429name = "test"
430description = "A skill"
431version = "0.1.0"
432
433[[tools]]
434name = "action"
435kind = "shell"
436command = "echo hello"
437"#;
438        let result = append_improvement_metadata(content, "2026-01-01T00:00:00Z", "Improved");
439        assert!(result.contains("[[tools]]"));
440        assert!(result.contains("echo hello"));
441    }
442
443    // ── Audit trail extraction ──────────────────────────────
444
445    #[test]
446    fn extract_audit_trail_from_content() {
447        let content = r#"[skill]
448name = "test"
449# Improvement: 2026-01-01T00:00:00Z
450# Reason: First improvement
451# Improvement: 2026-02-01T00:00:00Z
452# Reason: Second improvement
453"#;
454        let trail = extract_audit_trail(content);
455        assert!(trail.contains("First improvement"));
456        assert!(trail.contains("Second improvement"));
457        assert_eq!(trail.lines().count(), 4);
458    }
459
460    #[test]
461    fn extract_audit_trail_empty_when_none() {
462        let content = "[skill]\nname = \"test\"\n";
463        let trail = extract_audit_trail(content);
464        assert!(trail.is_empty());
465    }
466}