zeroclaw_runtime/skills/
improver.rs1use anyhow::{Context, Result, bail};
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::time::Instant;
10use zeroclaw_config::schema::SkillImprovementConfig;
11
12pub 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 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 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_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 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 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 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 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 let written = tokio::fs::read_to_string(&temp_path).await?;
101 if let Err(e) = validate_skill_content(&written) {
102 let _ = tokio::fs::remove_file(&temp_path).await;
104 bail!("Validation failed after write: {e}");
105 }
106
107 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 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
129pub fn validate_skill_content(content: &str) -> Result<()> {
132 if content.trim().is_empty() {
133 bail!("Skill content is empty");
134 }
135
136 #[derive(serde::Deserialize)]
138 struct Partial {
139 skill: PartialSkill,
140 }
141 #[derive(serde::Deserialize)]
142 struct PartialSkill {
143 name: Option<String>,
144 }
145
146 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
158fn append_improvement_metadata(content: &str, timestamp: &str, reason: &str) -> String {
160 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 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
182fn 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
194fn 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 #[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 #[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 #[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 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 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 let result = improver
366 .improve_skill("test-skill", "", "bad improvement")
367 .await;
368 assert!(result.is_err());
369
370 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 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 #[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 #[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}