Skip to main content

zeroclaw_runtime/skills/
creator.rs

1// Autonomous skill creation from successful multi-step task executions.
2//
3// After the agent completes a multi-step tool-call sequence, this module
4// can persist the execution as a reusable skill definition (SKILL.toml)
5// under `~/.zeroclaw/workspace/skills/<slug>/`.
6
7use anyhow::{Context, Result};
8use std::path::PathBuf;
9use zeroclaw_config::schema::SkillCreationConfig;
10use zeroclaw_memory::embeddings::EmbeddingProvider;
11use zeroclaw_memory::vector::cosine_similarity;
12
13/// A record of a single tool call executed during a task.
14#[derive(Debug, Clone)]
15pub struct ToolCallRecord {
16    pub name: String,
17    pub args: serde_json::Value,
18}
19
20/// Creates reusable skill definitions from successful multi-step executions.
21pub struct SkillCreator {
22    workspace_dir: PathBuf,
23    config: SkillCreationConfig,
24}
25
26impl SkillCreator {
27    pub fn new(workspace_dir: PathBuf, config: SkillCreationConfig) -> Self {
28        Self {
29            workspace_dir,
30            config,
31        }
32    }
33
34    /// Attempt to create a skill from a successful multi-step task execution.
35    /// Returns `Ok(Some(slug))` if a skill was created, `Ok(None)` if skipped
36    /// (disabled, duplicate, or insufficient tool calls).
37    pub async fn create_from_execution(
38        &self,
39        task_description: &str,
40        tool_calls: &[ToolCallRecord],
41        embedding_provider: Option<&dyn EmbeddingProvider>,
42    ) -> Result<Option<String>> {
43        if !self.config.enabled {
44            return Ok(None);
45        }
46
47        if tool_calls.len() < 2 {
48            return Ok(None);
49        }
50
51        // Deduplicate via embeddings when an embedding model_provider is available.
52        if let Some(model_provider) = embedding_provider
53            && model_provider.name() != "none"
54            && self.is_duplicate(task_description, model_provider).await?
55        {
56            return Ok(None);
57        }
58
59        let slug = Self::generate_slug(task_description);
60        if !Self::validate_slug(&slug) {
61            return Ok(None);
62        }
63
64        // Enforce LRU limit before writing a new skill.
65        self.enforce_lru_limit().await?;
66
67        let skill_dir = self.skills_dir().join(&slug);
68        tokio::fs::create_dir_all(&skill_dir)
69            .await
70            .with_context(|| {
71                format!(
72                    "Failed to create skill directory: {}",
73                    skill_dir.display().to_string()
74                )
75            })?;
76
77        let toml_content = Self::generate_skill_toml(&slug, task_description, tool_calls);
78        let toml_path = skill_dir.join("SKILL.toml");
79        tokio::fs::write(&toml_path, toml_content.as_bytes())
80            .await
81            .with_context(|| format!("Failed to write {}", toml_path.display().to_string()))?;
82
83        Ok(Some(slug))
84    }
85
86    /// Generate a URL-safe slug from a task description.
87    /// Alphanumeric and hyphens only, max 64 characters.
88    fn generate_slug(description: &str) -> String {
89        let slug: String = description
90            .to_lowercase()
91            .chars()
92            .map(|c| if c.is_alphanumeric() { c } else { '-' })
93            .collect();
94
95        // Collapse consecutive hyphens.
96        let mut collapsed = String::with_capacity(slug.len());
97        let mut prev_hyphen = false;
98        for c in slug.chars() {
99            if c == '-' {
100                if !prev_hyphen {
101                    collapsed.push('-');
102                }
103                prev_hyphen = true;
104            } else {
105                collapsed.push(c);
106                prev_hyphen = false;
107            }
108        }
109
110        // Trim leading/trailing hyphens, then truncate.
111        let trimmed = collapsed.trim_matches('-');
112        if trimmed.len() > 64 {
113            // Find the nearest valid character boundary at or before 64 bytes.
114            let safe_index = trimmed
115                .char_indices()
116                .map(|(i, _)| i)
117                .take_while(|&i| i <= 64)
118                .last()
119                .unwrap_or(0);
120            let truncated = &trimmed[..safe_index];
121            truncated.trim_end_matches('-').to_string()
122        } else {
123            trimmed.to_string()
124        }
125    }
126
127    /// Validate that a slug is non-empty, alphanumeric + hyphens, max 64 chars.
128    fn validate_slug(slug: &str) -> bool {
129        !slug.is_empty()
130            && slug.len() <= 64
131            && slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
132            && !slug.starts_with('-')
133            && !slug.ends_with('-')
134    }
135
136    /// Generate SKILL.toml content from task execution data.
137    fn generate_skill_toml(slug: &str, description: &str, tool_calls: &[ToolCallRecord]) -> String {
138        use std::fmt::Write;
139        let mut toml = String::new();
140        toml.push_str("[skill]\n");
141        let _ = writeln!(toml, "name = {}", toml_escape(slug));
142        let _ = writeln!(
143            toml,
144            "description = {}",
145            toml_escape(&format!("Auto-generated: {description}"))
146        );
147        toml.push_str("version = \"0.1.0\"\n");
148        toml.push_str("author = \"zeroclaw-auto\"\n");
149        toml.push_str("tags = [\"auto-generated\"]\n");
150
151        for call in tool_calls {
152            toml.push('\n');
153            toml.push_str("[[tools]]\n");
154            let _ = writeln!(toml, "name = {}", toml_escape(&call.name));
155            let _ = writeln!(
156                toml,
157                "description = {}",
158                toml_escape(&format!("Tool used in task: {}", call.name))
159            );
160            toml.push_str("kind = \"shell\"\n");
161
162            // Extract the command from args if available, otherwise use the tool name.
163            let command = call
164                .args
165                .get("command")
166                .and_then(serde_json::Value::as_str)
167                .unwrap_or(&call.name);
168            let _ = writeln!(toml, "command = {}", toml_escape(command));
169        }
170
171        toml
172    }
173
174    /// Check if a skill with a similar description already exists.
175    async fn is_duplicate(
176        &self,
177        description: &str,
178        embedding_provider: &dyn EmbeddingProvider,
179    ) -> Result<bool> {
180        let new_embedding = embedding_provider.embed_one(description).await?;
181        if new_embedding.is_empty() {
182            return Ok(false);
183        }
184
185        let skills_dir = self.skills_dir();
186        if !skills_dir.exists() {
187            return Ok(false);
188        }
189
190        let mut entries = tokio::fs::read_dir(&skills_dir).await?;
191        while let Some(entry) = entries.next_entry().await? {
192            let toml_path = entry.path().join("SKILL.toml");
193            if !toml_path.exists() {
194                continue;
195            }
196
197            let content = tokio::fs::read_to_string(&toml_path).await?;
198            // Extract description from the TOML to compare.
199            if let Some(desc) = extract_description_from_toml(&content) {
200                let existing_embedding = embedding_provider.embed_one(&desc).await?;
201                if !existing_embedding.is_empty() {
202                    #[allow(clippy::cast_possible_truncation)]
203                    let similarity =
204                        f64::from(cosine_similarity(&new_embedding, &existing_embedding));
205                    if similarity > self.config.similarity_threshold {
206                        return Ok(true);
207                    }
208                }
209            }
210        }
211
212        Ok(false)
213    }
214
215    /// Remove the oldest auto-generated skill when we exceed `max_skills`.
216    async fn enforce_lru_limit(&self) -> Result<()> {
217        let skills_dir = self.skills_dir();
218        if !skills_dir.exists() {
219            return Ok(());
220        }
221
222        let mut auto_skills: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
223
224        let mut entries = tokio::fs::read_dir(&skills_dir).await?;
225        while let Some(entry) = entries.next_entry().await? {
226            let toml_path = entry.path().join("SKILL.toml");
227            if !toml_path.exists() {
228                continue;
229            }
230
231            let content = tokio::fs::read_to_string(&toml_path).await?;
232            if content.contains("\"zeroclaw-auto\"") || content.contains("\"auto-generated\"") {
233                let modified = tokio::fs::metadata(&toml_path)
234                    .await?
235                    .modified()
236                    .unwrap_or(std::time::UNIX_EPOCH);
237                auto_skills.push((entry.path(), modified));
238            }
239        }
240
241        // If at or above the limit, remove the oldest.
242        if auto_skills.len() >= self.config.max_skills {
243            auto_skills.sort_by_key(|(_, modified)| *modified);
244            if let Some((oldest_dir, _)) = auto_skills.first() {
245                tokio::fs::remove_dir_all(oldest_dir)
246                    .await
247                    .with_context(|| {
248                        format!(
249                            "Failed to remove oldest auto-generated skill: {}",
250                            oldest_dir.display()
251                        )
252                    })?;
253            }
254        }
255
256        Ok(())
257    }
258
259    fn skills_dir(&self) -> PathBuf {
260        self.workspace_dir.join("skills")
261    }
262}
263
264/// Escape a string for TOML value (double-quoted).
265fn toml_escape(s: &str) -> String {
266    let escaped = s
267        .replace('\\', "\\\\")
268        .replace('"', "\\\"")
269        .replace('\n', "\\n")
270        .replace('\r', "\\r")
271        .replace('\t', "\\t");
272    format!("\"{escaped}\"")
273}
274
275/// Extract the description field from a SKILL.toml string.
276fn extract_description_from_toml(content: &str) -> Option<String> {
277    #[derive(serde::Deserialize)]
278    struct Partial {
279        skill: PartialSkill,
280    }
281    #[derive(serde::Deserialize)]
282    struct PartialSkill {
283        description: Option<String>,
284    }
285    toml::from_str::<Partial>(content)
286        .ok()
287        .and_then(|p| p.skill.description)
288}
289
290/// Extract `ToolCallRecord`s from the agent conversation history.
291///
292/// Scans assistant messages for tool call patterns (both JSON and XML formats)
293/// and returns records for each unique tool invocation.
294pub fn extract_tool_calls_from_history(
295    history: &[zeroclaw_providers::ChatMessage],
296) -> Vec<ToolCallRecord> {
297    let mut records = Vec::new();
298
299    for msg in history {
300        if msg.role != "assistant" {
301            continue;
302        }
303
304        // Try parsing as JSON (native tool_calls format).
305        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg.content)
306            && let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array())
307        {
308            for call in tool_calls {
309                if let Some(function) = call.get("function") {
310                    let name = function
311                        .get("name")
312                        .and_then(serde_json::Value::as_str)
313                        .unwrap_or("")
314                        .to_string();
315                    let args_str = function
316                        .get("arguments")
317                        .and_then(serde_json::Value::as_str)
318                        .unwrap_or("{}");
319                    let args = serde_json::from_str(args_str).unwrap_or_default();
320                    if !name.is_empty() {
321                        records.push(ToolCallRecord { name, args });
322                    }
323                }
324            }
325        }
326
327        // Also try XML tool call format: <tool_name>...</tool_name>
328        // Simple extraction for `<shell>{"command":"..."}</shell>` style tags.
329        let content = &msg.content;
330        let mut pos = 0;
331        while pos < content.len() {
332            if let Some(start) = content[pos..].find('<') {
333                let abs_start = pos + start;
334                if let Some(end) = content[abs_start..].find('>') {
335                    let tag = &content[abs_start + 1..abs_start + end];
336                    // Skip closing tags and meta tags.
337                    if tag.starts_with('/') || tag.starts_with('!') || tag.starts_with('?') {
338                        pos = abs_start + end + 1;
339                        continue;
340                    }
341                    let tag_name = tag.split_whitespace().next().unwrap_or(tag);
342                    let close_tag = format!("</{tag_name}>");
343                    if let Some(close_pos) = content[abs_start + end + 1..].find(&close_tag) {
344                        let inner = &content[abs_start + end + 1..abs_start + end + 1 + close_pos];
345                        let args: serde_json::Value =
346                            serde_json::from_str(inner.trim()).unwrap_or_default();
347                        // Only add if it looks like a tool call (not HTML/formatting tags).
348                        if tag_name != "tool_result"
349                            && tag_name != "tool_results"
350                            && !tag_name.contains(':')
351                            && args.is_object()
352                            && !args.as_object().is_none_or(|o| o.is_empty())
353                        {
354                            records.push(ToolCallRecord {
355                                name: tag_name.to_string(),
356                                args,
357                            });
358                        }
359                        pos = abs_start + end + 1 + close_pos + close_tag.len();
360                    } else {
361                        pos = abs_start + end + 1;
362                    }
363                } else {
364                    break;
365                }
366            } else {
367                break;
368            }
369        }
370    }
371
372    records
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use async_trait::async_trait;
379    use zeroclaw_memory::embeddings::{EmbeddingProvider, NoopEmbedding};
380
381    // ── Slug generation ──────────────────────────────────────────
382
383    #[test]
384    fn slug_basic() {
385        assert_eq!(
386            SkillCreator::generate_slug("Deploy to production"),
387            "deploy-to-production"
388        );
389    }
390
391    #[test]
392    fn slug_special_characters() {
393        assert_eq!(
394            SkillCreator::generate_slug("Build & test (CI/CD) pipeline!"),
395            "build-test-ci-cd-pipeline"
396        );
397    }
398
399    #[test]
400    fn slug_max_length() {
401        let long_desc = "a".repeat(100);
402        let slug = SkillCreator::generate_slug(&long_desc);
403        assert!(slug.len() <= 64);
404    }
405
406    #[test]
407    fn slug_leading_trailing_hyphens() {
408        let slug = SkillCreator::generate_slug("---hello world---");
409        assert!(!slug.starts_with('-'));
410        assert!(!slug.ends_with('-'));
411    }
412
413    #[test]
414    fn slug_consecutive_spaces() {
415        assert_eq!(SkillCreator::generate_slug("hello    world"), "hello-world");
416    }
417
418    #[test]
419    fn slug_empty_input() {
420        let slug = SkillCreator::generate_slug("");
421        assert!(slug.is_empty());
422    }
423
424    #[test]
425    fn slug_only_symbols() {
426        let slug = SkillCreator::generate_slug("!@#$%^&*()");
427        assert!(slug.is_empty());
428    }
429
430    #[test]
431    fn slug_unicode() {
432        let slug = SkillCreator::generate_slug("Deploy cafe app");
433        assert_eq!(slug, "deploy-cafe-app");
434    }
435
436    // ── Slug validation ──────────────────────────────────────────
437
438    #[test]
439    fn validate_slug_valid() {
440        assert!(SkillCreator::validate_slug("deploy-to-production"));
441        assert!(SkillCreator::validate_slug("a"));
442        assert!(SkillCreator::validate_slug("abc123"));
443    }
444
445    #[test]
446    fn validate_slug_invalid() {
447        assert!(!SkillCreator::validate_slug(""));
448        assert!(!SkillCreator::validate_slug("-starts-with-hyphen"));
449        assert!(!SkillCreator::validate_slug("ends-with-hyphen-"));
450        assert!(!SkillCreator::validate_slug("has spaces"));
451        assert!(!SkillCreator::validate_slug("has_underscores"));
452        assert!(!SkillCreator::validate_slug(&"a".repeat(65)));
453    }
454
455    // ── TOML generation ──────────────────────────────────────────
456
457    #[test]
458    fn toml_generation_valid_format() {
459        let calls = vec![
460            ToolCallRecord {
461                name: "shell".into(),
462                args: serde_json::json!({"command": "cargo build"}),
463            },
464            ToolCallRecord {
465                name: "shell".into(),
466                args: serde_json::json!({"command": "cargo test"}),
467            },
468        ];
469        let toml_str = SkillCreator::generate_skill_toml(
470            "build-and-test",
471            "Build and test the project",
472            &calls,
473        );
474
475        // Should parse as valid TOML.
476        let parsed: toml::Value =
477            toml::from_str(&toml_str).expect("Generated TOML should be valid");
478        let skill = parsed.get("skill").expect("Should have [skill] section");
479        assert_eq!(
480            skill.get("name").and_then(toml::Value::as_str),
481            Some("build-and-test")
482        );
483        assert_eq!(
484            skill.get("author").and_then(toml::Value::as_str),
485            Some("zeroclaw-auto")
486        );
487        assert_eq!(
488            skill.get("version").and_then(toml::Value::as_str),
489            Some("0.1.0")
490        );
491
492        let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
493        assert_eq!(tools.len(), 2);
494        assert_eq!(
495            tools[0].get("command").and_then(toml::Value::as_str),
496            Some("cargo build")
497        );
498    }
499
500    #[test]
501    fn toml_generation_escapes_quotes() {
502        let calls = vec![ToolCallRecord {
503            name: "shell".into(),
504            args: serde_json::json!({"command": "echo \"hello\""}),
505        }];
506        let toml_str =
507            SkillCreator::generate_skill_toml("echo-test", "Test \"quoted\" description", &calls);
508        let parsed: toml::Value =
509            toml::from_str(&toml_str).expect("TOML with quotes should be valid");
510        let desc = parsed
511            .get("skill")
512            .and_then(|s| s.get("description"))
513            .and_then(toml::Value::as_str)
514            .unwrap();
515        assert!(desc.contains("quoted"));
516    }
517
518    #[test]
519    fn toml_generation_no_command_arg() {
520        let calls = vec![ToolCallRecord {
521            name: "memory_store".into(),
522            args: serde_json::json!({"key": "foo", "value": "bar"}),
523        }];
524        let toml_str = SkillCreator::generate_skill_toml("memory-op", "Store to memory", &calls);
525        let parsed: toml::Value = toml::from_str(&toml_str).expect("TOML should be valid");
526        let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
527        // When no "command" arg exists, falls back to tool name.
528        assert_eq!(
529            tools[0].get("command").and_then(toml::Value::as_str),
530            Some("memory_store")
531        );
532    }
533
534    // ── TOML description extraction ──────────────────────────────
535
536    #[test]
537    fn extract_description_from_valid_toml() {
538        let content = r#"
539[skill]
540name = "test"
541description = "Auto-generated: Build project"
542version = "0.1.0"
543"#;
544        assert_eq!(
545            extract_description_from_toml(content),
546            Some("Auto-generated: Build project".into())
547        );
548    }
549
550    #[test]
551    fn extract_description_from_invalid_toml() {
552        assert_eq!(extract_description_from_toml("not valid toml {{"), None);
553    }
554
555    // ── Deduplication ────────────────────────────────────────────
556
557    /// A mock embedding model_provider that returns deterministic embeddings.
558    ///
559    /// The "new" description (first text embedded) always gets `[1, 0, 0]`.
560    /// The "existing" skill description (second text embedded) gets a vector
561    /// whose cosine similarity with `[1, 0, 0]` equals `self.similarity`.
562    struct MockEmbeddingProvider {
563        similarity: f32,
564        call_count: std::sync::atomic::AtomicUsize,
565    }
566
567    impl MockEmbeddingProvider {
568        fn new(similarity: f32) -> Self {
569            Self {
570                similarity,
571                call_count: std::sync::atomic::AtomicUsize::new(0),
572            }
573        }
574    }
575
576    #[async_trait]
577    impl EmbeddingProvider for MockEmbeddingProvider {
578        fn name(&self) -> &str {
579            "mock"
580        }
581        fn dimensions(&self) -> usize {
582            3
583        }
584        async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
585            Ok(texts
586                .iter()
587                .map(|_| {
588                    let call = self
589                        .call_count
590                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
591                    if call == 0 {
592                        // First call: the "new" description.
593                        vec![1.0, 0.0, 0.0]
594                    } else {
595                        // Subsequent calls: existing skill descriptions.
596                        // Produce a vector with the configured cosine similarity to [1,0,0].
597                        vec![
598                            self.similarity,
599                            (1.0 - self.similarity * self.similarity).sqrt(),
600                            0.0,
601                        ]
602                    }
603                })
604                .collect())
605        }
606    }
607
608    #[tokio::test]
609    async fn dedup_skips_similar_descriptions() {
610        let dir = tempfile::tempdir().unwrap();
611        let skills_dir = dir.path().join("skills").join("existing-skill");
612        tokio::fs::create_dir_all(&skills_dir).await.unwrap();
613        tokio::fs::write(
614            skills_dir.join("SKILL.toml"),
615            r#"
616[skill]
617name = "existing-skill"
618description = "Auto-generated: Build the project"
619version = "0.1.0"
620author = "zeroclaw-auto"
621tags = ["auto-generated"]
622"#,
623        )
624        .await
625        .unwrap();
626
627        let config = SkillCreationConfig {
628            enabled: true,
629            max_skills: 500,
630            similarity_threshold: 0.85,
631        };
632
633        // High similarity model_provider -> should detect as duplicate.
634        let model_provider = MockEmbeddingProvider::new(0.95);
635        let creator = SkillCreator::new(dir.path().to_path_buf(), config.clone());
636        assert!(
637            creator
638                .is_duplicate("Build the project", &model_provider)
639                .await
640                .unwrap()
641        );
642
643        // Low similarity model_provider -> not a duplicate.
644        let provider_low = MockEmbeddingProvider::new(0.3);
645        let creator2 = SkillCreator::new(dir.path().to_path_buf(), config);
646        assert!(
647            !creator2
648                .is_duplicate("Completely different task", &provider_low)
649                .await
650                .unwrap()
651        );
652    }
653
654    // ── LRU eviction ─────────────────────────────────────────────
655
656    #[tokio::test]
657    async fn lru_eviction_removes_oldest() {
658        let dir = tempfile::tempdir().unwrap();
659        let config = SkillCreationConfig {
660            enabled: true,
661            max_skills: 2,
662            similarity_threshold: 0.85,
663        };
664
665        let skills_dir = dir.path().join("skills");
666
667        // Create two auto-generated skills with different timestamps.
668        for (i, name) in ["old-skill", "new-skill"].iter().enumerate() {
669            let skill_dir = skills_dir.join(name);
670            tokio::fs::create_dir_all(&skill_dir).await.unwrap();
671            tokio::fs::write(
672                skill_dir.join("SKILL.toml"),
673                format!(
674                    r#"[skill]
675name = "{name}"
676description = "Auto-generated: Skill {i}"
677version = "0.1.0"
678author = "zeroclaw-auto"
679tags = ["auto-generated"]
680"#
681                ),
682            )
683            .await
684            .unwrap();
685            // Small delay to ensure different timestamps.
686            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
687        }
688
689        let creator = SkillCreator::new(dir.path().to_path_buf(), config);
690        creator.enforce_lru_limit().await.unwrap();
691
692        // The oldest skill should have been removed.
693        assert!(!skills_dir.join("old-skill").exists());
694        assert!(skills_dir.join("new-skill").exists());
695    }
696
697    // ── End-to-end: create_from_execution ────────────────────────
698
699    #[tokio::test]
700    async fn create_from_execution_disabled() {
701        let dir = tempfile::tempdir().unwrap();
702        let config = SkillCreationConfig {
703            enabled: false,
704            ..Default::default()
705        };
706        let creator = SkillCreator::new(dir.path().to_path_buf(), config);
707        let calls = vec![
708            ToolCallRecord {
709                name: "shell".into(),
710                args: serde_json::json!({"command": "ls"}),
711            },
712            ToolCallRecord {
713                name: "shell".into(),
714                args: serde_json::json!({"command": "pwd"}),
715            },
716        ];
717        let result = creator
718            .create_from_execution("List files", &calls, None)
719            .await
720            .unwrap();
721        assert!(result.is_none());
722    }
723
724    #[tokio::test]
725    async fn create_from_execution_insufficient_steps() {
726        let dir = tempfile::tempdir().unwrap();
727        let config = SkillCreationConfig {
728            enabled: true,
729            ..Default::default()
730        };
731        let creator = SkillCreator::new(dir.path().to_path_buf(), config);
732        let calls = vec![ToolCallRecord {
733            name: "shell".into(),
734            args: serde_json::json!({"command": "ls"}),
735        }];
736        let result = creator
737            .create_from_execution("List files", &calls, None)
738            .await
739            .unwrap();
740        assert!(result.is_none());
741    }
742
743    #[tokio::test]
744    async fn create_from_execution_success() {
745        let dir = tempfile::tempdir().unwrap();
746        let config = SkillCreationConfig {
747            enabled: true,
748            max_skills: 500,
749            similarity_threshold: 0.85,
750        };
751        let creator = SkillCreator::new(dir.path().to_path_buf(), config);
752        let calls = vec![
753            ToolCallRecord {
754                name: "shell".into(),
755                args: serde_json::json!({"command": "cargo build"}),
756            },
757            ToolCallRecord {
758                name: "shell".into(),
759                args: serde_json::json!({"command": "cargo test"}),
760            },
761        ];
762
763        // Use noop embedding (no deduplication).
764        let noop = NoopEmbedding;
765        let result = creator
766            .create_from_execution("Build and test", &calls, Some(&noop))
767            .await
768            .unwrap();
769        assert_eq!(result, Some("build-and-test".into()));
770
771        // Verify the skill directory and TOML were created.
772        let skill_dir = dir.path().join("skills").join("build-and-test");
773        assert!(skill_dir.exists());
774        let toml_content = tokio::fs::read_to_string(skill_dir.join("SKILL.toml"))
775            .await
776            .unwrap();
777        assert!(toml_content.contains("build-and-test"));
778        assert!(toml_content.contains("zeroclaw-auto"));
779    }
780
781    #[tokio::test]
782    async fn create_from_execution_with_dedup() {
783        let dir = tempfile::tempdir().unwrap();
784        let config = SkillCreationConfig {
785            enabled: true,
786            max_skills: 500,
787            similarity_threshold: 0.85,
788        };
789
790        // First, create an existing skill.
791        let skills_dir = dir.path().join("skills").join("existing");
792        tokio::fs::create_dir_all(&skills_dir).await.unwrap();
793        tokio::fs::write(
794            skills_dir.join("SKILL.toml"),
795            r#"[skill]
796name = "existing"
797description = "Auto-generated: Build and test"
798version = "0.1.0"
799author = "zeroclaw-auto"
800tags = ["auto-generated"]
801"#,
802        )
803        .await
804        .unwrap();
805
806        // High similarity model_provider -> should skip.
807        let model_provider = MockEmbeddingProvider::new(0.95);
808        let creator = SkillCreator::new(dir.path().to_path_buf(), config);
809        let calls = vec![
810            ToolCallRecord {
811                name: "shell".into(),
812                args: serde_json::json!({"command": "cargo build"}),
813            },
814            ToolCallRecord {
815                name: "shell".into(),
816                args: serde_json::json!({"command": "cargo test"}),
817            },
818        ];
819        let result = creator
820            .create_from_execution("Build and test", &calls, Some(&model_provider))
821            .await
822            .unwrap();
823        assert!(result.is_none());
824    }
825
826    // ── Tool call extraction from history ────────────────────────
827
828    #[test]
829    fn extract_from_empty_history() {
830        let history = vec![];
831        let records = extract_tool_calls_from_history(&history);
832        assert!(records.is_empty());
833    }
834
835    #[test]
836    fn extract_from_user_messages_only() {
837        use zeroclaw_providers::ChatMessage;
838        let history = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
839        let records = extract_tool_calls_from_history(&history);
840        assert!(records.is_empty());
841    }
842
843    // ── Fuzz-like tests for slug ─────────────────────────────────
844
845    #[test]
846    fn slug_fuzz_various_inputs() {
847        let inputs = [
848            "",
849            " ",
850            "---",
851            "a",
852            "hello world!",
853            "UPPER CASE",
854            "with-hyphens-already",
855            "with__underscores",
856            "123 numbers 456",
857            "emoji: cafe",
858            &"x".repeat(200),
859            "a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-0-1-2-3-4-5",
860        ];
861
862        for input in &inputs {
863            let slug = SkillCreator::generate_slug(input);
864            // Slug should always pass validation (or be empty for degenerate input).
865            if !slug.is_empty() {
866                assert!(
867                    SkillCreator::validate_slug(&slug),
868                    "Generated slug '{slug}' from '{input}' failed validation"
869                );
870            }
871        }
872    }
873
874    // ── Fuzz-like tests for TOML generation ──────────────────────
875
876    #[test]
877    fn toml_fuzz_various_inputs() {
878        let descriptions = [
879            "simple task",
880            "task with \"quotes\" and \\ backslashes",
881            "task with\nnewlines\r\nand tabs\there",
882            "",
883            &"long ".repeat(100),
884        ];
885
886        let args_variants = [
887            serde_json::json!({}),
888            serde_json::json!({"command": "echo hello"}),
889            serde_json::json!({"command": "echo \"hello world\"", "extra": 42}),
890        ];
891
892        for desc in &descriptions {
893            for args in &args_variants {
894                let calls = vec![
895                    ToolCallRecord {
896                        name: "tool1".into(),
897                        args: args.clone(),
898                    },
899                    ToolCallRecord {
900                        name: "tool2".into(),
901                        args: args.clone(),
902                    },
903                ];
904                let toml_str = SkillCreator::generate_skill_toml("test-slug", desc, &calls);
905                // Must always produce valid TOML.
906                let _parsed: toml::Value = toml::from_str(&toml_str)
907                    .unwrap_or_else(|e| panic!("Invalid TOML for desc '{desc}': {e}\n{toml_str}"));
908            }
909        }
910    }
911}