Skip to main content

harness_loop/experience/
distill.rs

1//! `SkillDistiller` — turn a run that worked into a reusable skill.
2//!
3//! The proactive half of the learning loop. `skill_manage` already lets a model
4//! *choose* to write a skill; nothing ever asked it to. After a run ends, this
5//! decides whether the episode is worth generalising and, if so, spends one
6//! model call to draft an agentskills.io-compliant `SKILL.md`.
7//!
8//! ## The gate is arithmetic, not a model call
9//!
10//! Asking "was that worth remembering?" with an LLM every turn costs a call per
11//! turn to answer "no" almost every time. [`DistillPolicy`] is instead four
12//! cheap, inspectable predicates over the [`Episode`] plus a text-similarity
13//! check against the skills already on disk. Only when all five pass does a
14//! model see anything. On a chatty assistant the gate rejects nearly every run
15//! for free.
16//!
17//! The default gate:
18//!
19//! | check | default | why |
20//! |---|---|---|
21//! | run succeeded | required | a procedure distilled from a failure teaches failure |
22//! | ≥ N tool calls | 4 | fewer than that is an answer, not a procedure |
23//! | ≥ M distinct tools | 2 | `shell` six times is one move repeated |
24//! | situation length | 16 chars | "thanks!" has nothing to generalise |
25//! | no similar skill | 0.6 overlap | see [`DistillPolicy::similarity_threshold`] |
26//!
27//! ## Nothing is written to disk
28//!
29//! [`SkillDistiller::distill`] returns a [`SkillDraft`]. A multi-tenant server
30//! must scope the write per user, and many hosts want a human to approve a
31//! machine-authored skill before agents start following it — neither is
32//! possible if the distiller writes. [`SkillDraft::write_to`] and
33//! [`SkillDistiller::distill_and_write`] are the opt-in convenience for hosts
34//! that don't need either.
35//!
36//! ```ignore
37//! let distiller = SkillDistiller::new(model.clone());
38//! let existing = existing_skills_in(&user_skills_dir);
39//! match distiller.distill(&episode, &existing).await? {
40//!     Distillation::Drafted(draft) => queue_for_review(user_id, draft),
41//!     Distillation::Skipped(why)   => tracing::debug!(?why, "no skill distilled"),
42//! }
43//! ```
44
45use crate::experience::episode::Episode;
46use crate::experience::llm;
47use crate::experience::revise::RevisionLedger;
48use crate::experience::skillmd;
49use harness_core::{Model, SkillError, SkillManifest};
50use std::path::{Path, PathBuf};
51use std::sync::Arc;
52
53/// When the distiller is allowed to spend a model call.
54#[derive(Debug, Clone, PartialEq)]
55pub struct DistillPolicy {
56    /// Only distil runs the host marked successful. Unknown counts as "no" —
57    /// see [`Episode::success`].
58    pub require_success: bool,
59    /// Minimum total tool calls. A run that answered from context reveals no
60    /// procedure worth writing down.
61    pub min_tool_calls: usize,
62    /// Minimum *distinct* tools. Guards the degenerate case the raw count
63    /// misses: six `shell` calls in a row is one move repeated, not a
64    /// multi-step approach.
65    pub min_distinct_tools: usize,
66    /// Minimum trimmed length of the situation text.
67    pub min_situation_chars: usize,
68    /// Overlap-similarity above which an existing skill counts as "we already
69    /// have this" — the defence against a chatty user accumulating 200
70    /// near-identical skills.
71    ///
72    /// 0.6 on [`skillmd::overlap_similarity`] means "60% of the shorter text's
73    /// topical tokens also appear in the longer one". Tuned against the
74    /// realistic lopsided pair (a one-sentence situation vs a routing-cue-padded
75    /// description); raise it toward 0.8 for a host that would rather have a
76    /// near-duplicate than miss a real skill.
77    pub similarity_threshold: f32,
78    /// Output-token ceiling for the single distillation call.
79    pub max_output_tokens: u32,
80}
81
82impl Default for DistillPolicy {
83    fn default() -> Self {
84        Self {
85            require_success: true,
86            min_tool_calls: 4,
87            min_distinct_tools: 2,
88            min_situation_chars: 16,
89            similarity_threshold: 0.6,
90            max_output_tokens: 1_200,
91        }
92    }
93}
94
95/// Why the gate declined to spend a model call. Every variant carries the
96/// numbers behind the decision so a host can log or tune against it.
97#[derive(Debug, Clone, PartialEq)]
98#[non_exhaustive]
99pub enum SkipReason {
100    /// The run failed, or nobody said whether it worked.
101    NotSuccessful,
102    TooFewToolCalls {
103        got: usize,
104        need: usize,
105    },
106    TooFewDistinctTools {
107        got: usize,
108        need: usize,
109    },
110    SituationTooShort {
111        got: usize,
112        need: usize,
113    },
114    /// A skill covering this already exists.
115    DuplicateSkill {
116        existing: String,
117        similarity: f32,
118        threshold: f32,
119    },
120    /// The drafted name collides with a skill already on disk. Distinct from
121    /// `DuplicateSkill`: the *episode* looked novel, but the model landed on a
122    /// name that's taken, which usually means the topics really do overlap.
123    NameTaken {
124        existing: String,
125    },
126}
127
128/// Outcome of [`SkillDistiller::gate`].
129#[derive(Debug, Clone, PartialEq)]
130pub enum GateDecision {
131    /// Worth a model call.
132    Distill,
133    Skip(SkipReason),
134}
135
136/// Outcome of [`SkillDistiller::distill`].
137#[derive(Debug, Clone, PartialEq)]
138pub enum Distillation {
139    Drafted(SkillDraft),
140    Skipped(SkipReason),
141}
142
143impl Distillation {
144    pub fn draft(&self) -> Option<&SkillDraft> {
145        match self {
146            Distillation::Drafted(d) => Some(d),
147            Distillation::Skipped(_) => None,
148        }
149    }
150    pub fn into_draft(self) -> Option<SkillDraft> {
151        match self {
152            Distillation::Drafted(d) => Some(d),
153            Distillation::Skipped(_) => None,
154        }
155    }
156}
157
158#[derive(Debug, thiserror::Error)]
159#[non_exhaustive]
160pub enum DistillError {
161    #[error("distillation model call failed: {0}")]
162    Model(String),
163    #[error("model returned no usable draft: {0}")]
164    BadDraft(String),
165    /// The assembled draft failed this workspace's own skill rules. Reaching
166    /// this is a bug in the assembler, not a warning about the model — the
167    /// point of building the frontmatter in Rust is that it cannot happen.
168    #[error("assembled draft is not a valid skill: {}", .0.join("; "))]
169    Invalid(Vec<String>),
170    #[error("writing skill: {0}")]
171    Write(#[from] SkillError),
172}
173
174/// A skill that already exists, reduced to what duplicate detection needs.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ExistingSkill {
177    pub name: String,
178    pub description: String,
179}
180
181impl ExistingSkill {
182    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
183        Self {
184            name: name.into(),
185            description: description.into(),
186        }
187    }
188    pub fn from_manifest(m: &SkillManifest) -> Self {
189        Self::new(&m.name, &m.description)
190    }
191}
192
193/// Every skill under `<root>/<name>/SKILL.md`, as duplicate-check input.
194///
195/// A missing or unreadable root yields an empty list rather than an error: "no
196/// skills yet" is the normal state of a brand-new tenant, and it is also the
197/// answer that makes the gate *permissive*, which is the right failure mode for
198/// a first-ever skill.
199pub fn existing_skills_in(root: &Path) -> Vec<ExistingSkill> {
200    use harness_core::Skill as _;
201    match harness_context::skills::scan_skills_root(root) {
202        Ok(skills) => skills
203            .iter()
204            .map(|s| ExistingSkill::from_manifest(s.manifest()))
205            .collect(),
206        Err(e) => {
207            tracing::debug!(root = %root.display(), error = %e, "no existing skills to compare against");
208            Vec::new()
209        }
210    }
211}
212
213/// A drafted, not-yet-persisted skill.
214///
215/// Guaranteed by construction to pass `harness_context::skills::validate` and to produce
216/// no Error/Warning lint findings — [`SkillDistiller::distill`] refuses to
217/// return one that doesn't.
218#[derive(Debug, Clone, PartialEq)]
219pub struct SkillDraft {
220    /// agentskills.io-legal name; also the directory name it must be written to.
221    pub name: String,
222    /// One-line description carrying a routing cue.
223    pub description: String,
224    /// Markdown body — no frontmatter.
225    pub body: String,
226    /// The episode situation this was distilled from, kept for provenance and
227    /// for a human reviewing the draft.
228    pub source_situation: String,
229    /// Frontmatter `metadata`, pre-stamped with the revision ledger at 0.
230    pub metadata: std::collections::BTreeMap<String, serde_json::Value>,
231}
232
233impl SkillDraft {
234    pub fn manifest(&self) -> SkillManifest {
235        skillmd::manifest(&self.name, &self.description, self.metadata.clone())
236    }
237
238    /// The full `SKILL.md` text: frontmatter + body.
239    pub fn to_skill_md(&self) -> String {
240        skillmd::render_skill_md(&self.manifest(), &self.body)
241    }
242
243    /// Re-check the draft against `harness_context::skills`' validator and linter.
244    pub fn validate(&self) -> Result<(), DistillError> {
245        skillmd::validate_and_lint(&self.manifest(), &self.body).map_err(DistillError::Invalid)
246    }
247
248    /// Opt-in persistence: write `<skills_root>/<name>/SKILL.md`.
249    ///
250    /// Delegates to `harness_context::skills::write_skill_md`, which validates by loading
251    /// the file back and rolls the write back if it doesn't load — so a bad
252    /// draft cannot leave a broken skill behind.
253    pub fn write_to(&self, skills_root: &Path) -> Result<PathBuf, SkillError> {
254        harness_context::skills::write_skill_md(skills_root, &self.name, &self.to_skill_md())
255    }
256}
257
258/// Decides whether a finished run is worth a skill, and drafts it.
259pub struct SkillDistiller {
260    model: Arc<dyn Model>,
261    policy: DistillPolicy,
262}
263
264impl SkillDistiller {
265    pub fn new(model: Arc<dyn Model>) -> Self {
266        Self {
267            model,
268            policy: DistillPolicy::default(),
269        }
270    }
271
272    pub fn with_policy(mut self, policy: DistillPolicy) -> Self {
273        self.policy = policy;
274        self
275    }
276
277    pub fn with_min_tool_calls(mut self, n: usize) -> Self {
278        self.policy.min_tool_calls = n;
279        self
280    }
281
282    pub fn with_similarity_threshold(mut self, t: f32) -> Self {
283        self.policy.similarity_threshold = t;
284        self
285    }
286
287    pub fn policy(&self) -> &DistillPolicy {
288        &self.policy
289    }
290
291    /// The whole trigger decision, with no model call and no I/O.
292    ///
293    /// Checks run cheapest-first and return the *first* failure, so the reason a
294    /// host logs is the most fundamental one rather than an incidental later
295    /// check.
296    pub fn gate(&self, ep: &Episode, existing: &[ExistingSkill]) -> GateDecision {
297        let p = &self.policy;
298        if p.require_success && !ep.succeeded() {
299            return GateDecision::Skip(SkipReason::NotSuccessful);
300        }
301        if ep.tools.len() < p.min_tool_calls {
302            return GateDecision::Skip(SkipReason::TooFewToolCalls {
303                got: ep.tools.len(),
304                need: p.min_tool_calls,
305            });
306        }
307        let distinct = ep
308            .tools
309            .iter()
310            .collect::<std::collections::HashSet<_>>()
311            .len();
312        if distinct < p.min_distinct_tools {
313            return GateDecision::Skip(SkipReason::TooFewDistinctTools {
314                got: distinct,
315                need: p.min_distinct_tools,
316            });
317        }
318        let situation_len = ep.situation.trim().chars().count();
319        if situation_len < p.min_situation_chars {
320            return GateDecision::Skip(SkipReason::SituationTooShort {
321                got: situation_len,
322                need: p.min_situation_chars,
323            });
324        }
325        if let Some((name, sim)) = self.nearest(&ep.situation, existing) {
326            return GateDecision::Skip(SkipReason::DuplicateSkill {
327                existing: name,
328                similarity: sim,
329                threshold: p.similarity_threshold,
330            });
331        }
332        GateDecision::Distill
333    }
334
335    /// The existing skill most similar to `text`, if any clears the threshold.
336    ///
337    /// Compares against `name + description` because a good description already
338    /// enumerates the situations the skill covers — the exact thing an episode's
339    /// situation is a sample of.
340    ///
341    /// **Why the skills registry and not `ExperienceStore::recall`.** Recalling
342    /// similar *episodes* answers a different question: "have I done this
343    /// before?" A chatty user does the same task weekly, so on the second run
344    /// episode-similarity would fire and suppress the very first distillation —
345    /// while doing nothing whatsoever to stop the 200 near-identical skills this
346    /// check exists to prevent. The only source of truth for "does a skill for
347    /// this already exist" is the skills on disk.
348    fn nearest(&self, text: &str, existing: &[ExistingSkill]) -> Option<(String, f32)> {
349        existing
350            .iter()
351            .map(|s| {
352                let hay = format!("{} {}", s.name.replace('-', " "), s.description);
353                (s.name.clone(), skillmd::overlap_similarity(text, &hay))
354            })
355            .filter(|(_, sim)| *sim >= self.policy.similarity_threshold)
356            .max_by(|a, b| a.1.total_cmp(&b.1))
357    }
358
359    /// Gate, then (only if it passes) spend one model call to draft a skill.
360    ///
361    /// Never writes to disk. `existing` is the duplicate-suppression corpus —
362    /// use [`existing_skills_in`] to build it from a skills root, or pass the
363    /// caller's own per-tenant view.
364    pub async fn distill(
365        &self,
366        ep: &Episode,
367        existing: &[ExistingSkill],
368    ) -> Result<Distillation, DistillError> {
369        match self.gate(ep, existing) {
370            GateDecision::Skip(why) => return Ok(Distillation::Skipped(why)),
371            GateDecision::Distill => {}
372        }
373
374        let raw = llm::one_shot(
375            &self.model,
376            distill_prompt(ep),
377            self.policy.max_output_tokens,
378        )
379        .await
380        .map_err(DistillError::Model)?;
381        let json = llm::extract_json(&raw).ok_or_else(|| {
382            DistillError::BadDraft(format!(
383                "no JSON object in a {} char reply",
384                raw.trim().len()
385            ))
386        })?;
387
388        let draft = assemble_draft(ep, &json)?;
389
390        // Second duplicate pass, now against what the model actually produced.
391        // The episode's situation can read as novel while the model, seeing the
392        // same task through a generalising lens, lands on a skill that already
393        // exists — this catches that.
394        if let Some(clash) = existing.iter().find(|s| s.name == draft.name) {
395            return Ok(Distillation::Skipped(SkipReason::NameTaken {
396                existing: clash.name.clone(),
397            }));
398        }
399        let drafted_text = format!("{} {}", draft.name.replace('-', " "), draft.description);
400        if let Some((name, sim)) = self.nearest(&drafted_text, existing) {
401            return Ok(Distillation::Skipped(SkipReason::DuplicateSkill {
402                existing: name,
403                similarity: sim,
404                threshold: self.policy.similarity_threshold,
405            }));
406        }
407
408        draft.validate()?;
409        Ok(Distillation::Drafted(draft))
410    }
411
412    /// Convenience for hosts that need neither per-tenant scoping nor a human
413    /// gate: scan `skills_root` for duplicates, distil, and write on success.
414    ///
415    /// Returns the written path, or `None` when the gate declined.
416    pub async fn distill_and_write(
417        &self,
418        ep: &Episode,
419        skills_root: &Path,
420    ) -> Result<Option<PathBuf>, DistillError> {
421        let existing = existing_skills_in(skills_root);
422        match self.distill(ep, &existing).await? {
423            Distillation::Skipped(why) => {
424                tracing::debug!(?why, "no skill distilled");
425                Ok(None)
426            }
427            Distillation::Drafted(draft) => Ok(Some(draft.write_to(skills_root)?)),
428        }
429    }
430}
431
432/// The single distillation prompt. Asks for *fields*, never for a finished
433/// SKILL.md — see [`crate::experience::skillmd`] for why that inversion is what makes the
434/// output guaranteed-valid.
435fn distill_prompt(ep: &Episode) -> String {
436    let tools = if ep.tools.is_empty() {
437        "(none)".to_string()
438    } else {
439        ep.tools.join(" → ")
440    };
441    format!(
442        "You are distilling procedural memory for an AI agent.\n\n\
443         A task just finished successfully. Turn it into a REUSABLE skill: a procedure a \
444         future agent can follow for the same CLASS of task. Generalise. Do not write a \
445         diary entry about this one run, and do not bake in one-off values (this ticket \
446         number, this filename) — describe the role each played instead.\n\n\
447         ---- THE RUN ----\n\
448         SITUATION: {situation}\n\
449         TOOLS CALLED, IN ORDER: {tools}\n\
450         OUTCOME: {outcome}\n\
451         ---- END ----\n\n\
452         Reply with ONLY a JSON object, no prose around it:\n\
453         {{\n\
454         \x20 \"name\": \"lowercase-hyphenated, at most 5 words, class-level (e.g. \
455         \\\"deploy-runbook\\\", never \\\"fix-bug-1234\\\")\",\n\
456         \x20 \"title\": \"short human title, Title Case\",\n\
457         \x20 \"description\": \"1-2 sentences: what the skill does AND when to use it. \
458         Begin the second half with 'Use when'.\",\n\
459         \x20 \"steps\": [\"imperative step\", \"...\"],\n\
460         \x20 \"pitfalls\": [\"a gotcha a future agent would otherwise hit\", \"...\"]\n\
461         }}\n\n\
462         Rules: 3 to 10 steps. Name the concrete tool or command in a step where one \
463         applies. `pitfalls` may be empty if you have nothing honest to put there.",
464        situation = ep.situation.trim(),
465        outcome = ep.outcome.trim(),
466    )
467}
468
469/// Turn the model's JSON fields into a valid draft, repairing anything the spec
470/// cares about rather than rejecting it.
471fn assemble_draft(ep: &Episode, json: &serde_json::Value) -> Result<SkillDraft, DistillError> {
472    let raw_name = llm::str_field(json, "name").unwrap_or_default();
473    let mut name = skillmd::slugify(&raw_name);
474    if name.is_empty() {
475        // Fall back to the situation rather than failing: a model that gave us
476        // good steps under a useless name is still worth a skill.
477        name = skillmd::slugify(&ep.situation);
478    }
479    if name.is_empty() {
480        return Err(DistillError::BadDraft(
481            "no usable skill name could be derived from the model reply or the episode".into(),
482        ));
483    }
484
485    let steps = llm::str_list(json, "steps");
486    if steps.is_empty() {
487        return Err(DistillError::BadDraft(
488            "model returned no steps; a skill with no procedure is not a skill".into(),
489        ));
490    }
491    let pitfalls = llm::str_list(json, "pitfalls");
492
493    let description = skillmd::finalize_description(
494        &llm::str_field(json, "description").unwrap_or_default(),
495        &ep.situation,
496        &name.replace('-', " "),
497    );
498
499    let title = llm::str_field(json, "title").unwrap_or_else(|| title_from_slug(&name));
500    let body = render_body(&title, &description, &ep.situation, &steps, &pitfalls);
501
502    let mut metadata = std::collections::BTreeMap::new();
503    metadata.insert(
504        crate::experience::revise::EXPERIENCE_METADATA_KEY.to_string(),
505        serde_json::to_value(RevisionLedger::distilled()).unwrap_or(serde_json::Value::Null),
506    );
507
508    Ok(SkillDraft {
509        name,
510        description,
511        body,
512        source_situation: ep.situation.trim().to_string(),
513        metadata,
514    })
515}
516
517fn title_from_slug(slug: &str) -> String {
518    slug.split('-')
519        .map(|w| {
520            let mut c = w.chars();
521            match c.next() {
522                Some(f) => f.to_ascii_uppercase().to_string() + c.as_str(),
523                None => String::new(),
524            }
525        })
526        .collect::<Vec<_>>()
527        .join(" ")
528}
529
530/// Assemble the markdown body.
531///
532/// The `## When this applies` section is not decoration: it carries the source
533/// situation into the file (provenance a human reviewer needs) and it is what
534/// guarantees the body clears lint R3's 50-character floor even when a model
535/// returns three terse steps.
536fn render_body(
537    title: &str,
538    description: &str,
539    situation: &str,
540    steps: &[String],
541    pitfalls: &[String],
542) -> String {
543    let mut b = format!("# {title}\n\n{description}\n\n## When this applies\n\n");
544    b.push_str(situation.trim());
545    b.push_str("\n\n## Steps\n\n");
546    for (i, s) in steps.iter().enumerate() {
547        // Strip any numbering the model already added, so we don't emit "1. 1. …".
548        let s = s
549            .trim()
550            .trim_start_matches(|c: char| c.is_ascii_digit())
551            .trim_start_matches(['.', ')', '-', ' ']);
552        b.push_str(&format!("{}. {}\n", i + 1, s.trim()));
553    }
554    if !pitfalls.is_empty() {
555        b.push_str("\n## Pitfalls\n\n");
556        for p in pitfalls {
557            b.push_str(&format!(
558                "- {}\n",
559                p.trim().trim_start_matches(['-', '*']).trim()
560            ));
561        }
562    }
563    b
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use harness_models::{MockModel, MockResponse};
570
571    /// A well-formed distillation reply.
572    fn good_reply() -> String {
573        serde_json::json!({
574            "name": "Deploy Runbook",
575            "title": "Deploy Runbook",
576            "description": "Ship the marketing site to production and confirm it is live.",
577            "steps": [
578                "Read deploy.toml to confirm the target environment.",
579                "Run `cargo build --release` and check it exits zero.",
580                "Run deploy.sh with the target from step 1.",
581                "Fetch the public URL and assert it returns 200."
582            ],
583            "pitfalls": ["deploy.sh is not idempotent; do not re-run it after a partial failure"]
584        })
585        .to_string()
586    }
587
588    fn complex_episode() -> Episode {
589        Episode::new(
590            "the user asked to deploy the marketing site to production and verify it is live",
591            "built, ran deploy.sh, confirmed the site returns 200",
592        )
593        .with_tools([
594            "read_file",
595            "shell",
596            "write_file",
597            "shell",
598            "web_fetch",
599            "read_file",
600        ])
601        .with_success(true)
602    }
603
604    fn model(reply: &str) -> Arc<dyn Model> {
605        Arc::new(MockModel::new().script(MockResponse::text(reply)))
606    }
607
608    fn tmp_root(tag: &str) -> PathBuf {
609        use std::sync::atomic::{AtomicU64, Ordering};
610        static SEQ: AtomicU64 = AtomicU64::new(0);
611        let n = std::time::SystemTime::now()
612            .duration_since(std::time::UNIX_EPOCH)
613            .unwrap()
614            .as_nanos();
615        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
616        let p = std::env::temp_dir().join(format!(
617            "harness-distill-{tag}-{}-{n}-{seq}",
618            std::process::id()
619        ));
620        std::fs::create_dir_all(&p).unwrap();
621        p
622    }
623
624    // ---- gate -------------------------------------------------------------
625
626    #[test]
627    fn gate_rejects_a_trivial_one_tool_episode() {
628        let d = SkillDistiller::new(model(""));
629        let ep = Episode::new("what time is it in Tokyo?", "answered: 3pm JST")
630            .with_tools(["get_time"])
631            .with_success(true);
632        assert_eq!(
633            d.gate(&ep, &[]),
634            GateDecision::Skip(SkipReason::TooFewToolCalls { got: 1, need: 4 })
635        );
636    }
637
638    #[test]
639    fn gate_accepts_a_six_tool_successful_episode() {
640        let d = SkillDistiller::new(model(""));
641        assert_eq!(d.gate(&complex_episode(), &[]), GateDecision::Distill);
642    }
643
644    #[test]
645    fn gate_rejects_a_failed_or_unmarked_run() {
646        let d = SkillDistiller::new(model(""));
647        let mut ep = complex_episode();
648        ep.success = Some(false);
649        assert_eq!(
650            d.gate(&ep, &[]),
651            GateDecision::Skip(SkipReason::NotSuccessful)
652        );
653        ep.success = None;
654        assert_eq!(
655            d.gate(&ep, &[]),
656            GateDecision::Skip(SkipReason::NotSuccessful),
657            "an unconfirmed run must be treated as not-successful"
658        );
659    }
660
661    #[test]
662    fn gate_rejects_one_move_repeated() {
663        let d = SkillDistiller::new(model(""));
664        let ep = Episode::new(
665            "grep the repo for every TODO comment and list them",
666            "listed 41 TODOs",
667        )
668        .with_tools(["shell", "shell", "shell", "shell", "shell"])
669        .with_success(true);
670        assert_eq!(
671            d.gate(&ep, &[]),
672            GateDecision::Skip(SkipReason::TooFewDistinctTools { got: 1, need: 2 })
673        );
674    }
675
676    #[tokio::test]
677    async fn gate_rejection_spends_no_model_call() {
678        let mock = Arc::new(MockModel::new().script(MockResponse::text(good_reply())));
679        let d = SkillDistiller::new(mock.clone() as Arc<dyn Model>);
680        let trivial = Episode::new("hi", "hello").with_success(true);
681        let out = d.distill(&trivial, &[]).await.unwrap();
682        assert!(matches!(out, Distillation::Skipped(_)));
683        assert_eq!(
684            mock.call_count(),
685            0,
686            "the gate exists so that a boring run costs zero tokens"
687        );
688    }
689
690    // ---- drafting ---------------------------------------------------------
691
692    #[tokio::test]
693    async fn distilled_draft_passes_validate_and_lint() {
694        let d = SkillDistiller::new(model(&good_reply()));
695        let draft = d
696            .distill(&complex_episode(), &[])
697            .await
698            .unwrap()
699            .into_draft()
700            .expect("gate should pass");
701
702        // The model's "Deploy Runbook" became a spec-legal name.
703        assert_eq!(draft.name, "deploy-runbook");
704        harness_context::skills::validate_name(&draft.name).unwrap();
705
706        // Straight through this workspace's own validator + linter.
707        let manifest = draft.manifest();
708        harness_context::skills::validate(&manifest).expect("validate");
709        let findings =
710            harness_context::skills::lint_skills(&[harness_context::skills::FileSkill::new(
711                manifest,
712                draft.body.clone(),
713                Vec::new(),
714            )]);
715        let loud: Vec<_> = findings
716            .iter()
717            .filter(|f| f.severity != harness_context::skills::LintSeverity::Info)
718            .collect();
719        assert!(loud.is_empty(), "lint complained: {loud:#?}");
720        assert!(findings.is_empty(), "even info findings: {findings:#?}");
721    }
722
723    #[tokio::test]
724    async fn draft_round_trips_through_write_skill_md() {
725        let root = tmp_root("roundtrip");
726        let d = SkillDistiller::new(model(&good_reply()));
727        let draft = d
728            .distill(&complex_episode(), &[])
729            .await
730            .unwrap()
731            .into_draft()
732            .unwrap();
733
734        // write_skill_md validates by loading the file back, and rolls back if
735        // it doesn't load — so a successful write IS the round-trip proof.
736        let path = draft
737            .write_to(&root)
738            .expect("write_skill_md must accept it");
739        assert!(path.exists());
740
741        let loaded = harness_context::skills::load_skill_dir(&root.join(&draft.name)).unwrap();
742        assert_eq!(loaded.manifest().name, draft.name);
743        assert_eq!(loaded.manifest().description, draft.description);
744        assert!(loaded.body().contains("## Steps"));
745        assert!(loaded.body().contains("1. Read deploy.toml"));
746        // The revision ledger survived the YAML round-trip at 0.
747        let ledger = RevisionLedger::from_manifest(loaded.manifest());
748        assert_eq!(ledger.revision, 0);
749        assert_eq!(ledger.origin.as_deref(), Some("distilled"));
750
751        // And it is discoverable by a plain registry scan.
752        let scanned = existing_skills_in(&root);
753        assert_eq!(scanned.len(), 1);
754        assert_eq!(scanned[0].name, "deploy-runbook");
755
756        let _ = std::fs::remove_dir_all(&root);
757    }
758
759    #[tokio::test]
760    async fn a_terse_model_reply_still_yields_a_lint_clean_skill() {
761        // Minimum viable reply: no title, no pitfalls, a description far too
762        // short and with no routing cue. The assembler must repair all of it.
763        let reply = serde_json::json!({
764            "name": "site-deploy",
765            "description": "Deploys.",
766            "steps": ["1. build", "2. ship"]
767        })
768        .to_string();
769        let d = SkillDistiller::new(model(&reply));
770        let draft = d
771            .distill(&complex_episode(), &[])
772            .await
773            .unwrap()
774            .into_draft()
775            .unwrap();
776        draft.validate().expect("repaired draft must be valid");
777        assert!(draft.description.to_ascii_lowercase().contains("use when"));
778        assert!(draft.description.len() >= 60);
779        // Model-supplied numbering must not be doubled up.
780        assert!(draft.body.contains("1. build"), "{}", draft.body);
781        assert!(!draft.body.contains("1. 1."), "{}", draft.body);
782    }
783
784    #[tokio::test]
785    async fn a_reply_with_no_steps_is_an_error_not_a_skill() {
786        let reply = serde_json::json!({"name": "x", "description": "y"}).to_string();
787        let d = SkillDistiller::new(model(&reply));
788        let err = d.distill(&complex_episode(), &[]).await.unwrap_err();
789        assert!(matches!(err, DistillError::BadDraft(_)), "{err}");
790    }
791
792    #[tokio::test]
793    async fn a_non_json_reply_is_an_error() {
794        let d = SkillDistiller::new(model("I'm sorry, I can't help with that."));
795        let err = d.distill(&complex_episode(), &[]).await.unwrap_err();
796        assert!(matches!(err, DistillError::BadDraft(_)), "{err}");
797    }
798
799    // ---- duplicate suppression -------------------------------------------
800
801    #[tokio::test]
802    async fn near_duplicate_suppression_fires_before_the_model_call() {
803        let mock = Arc::new(MockModel::new().script(MockResponse::text(good_reply())));
804        let d = SkillDistiller::new(mock.clone() as Arc<dyn Model>);
805        let existing = vec![ExistingSkill::new(
806            "site-deployment",
807            "Deploy the marketing site to production and verify it. Use when the user asks to \
808             ship, release, or push the site live.",
809        )];
810
811        let out = d.distill(&complex_episode(), &existing).await.unwrap();
812        match out {
813            Distillation::Skipped(SkipReason::DuplicateSkill {
814                existing,
815                similarity,
816                threshold,
817            }) => {
818                assert_eq!(existing, "site-deployment");
819                assert!(similarity >= threshold, "{similarity} < {threshold}");
820            }
821            other => panic!("expected duplicate suppression, got {other:?}"),
822        }
823        assert_eq!(mock.call_count(), 0, "a duplicate must cost zero tokens");
824    }
825
826    #[tokio::test]
827    async fn an_unrelated_existing_skill_does_not_suppress() {
828        let d = SkillDistiller::new(model(&good_reply()));
829        let existing = vec![ExistingSkill::new(
830            "invoice-extraction",
831            "Pull line items out of a scanned PDF invoice into a spreadsheet row. Use when the \
832             user uploads a supplier invoice.",
833        )];
834        let out = d.distill(&complex_episode(), &existing).await.unwrap();
835        assert!(out.draft().is_some(), "{out:?}");
836    }
837
838    #[tokio::test]
839    async fn a_taken_name_is_caught_after_drafting() {
840        // The situation is worded so it does NOT clear the pre-call similarity
841        // check, but the model still names the skill `deploy-runbook`.
842        let d = SkillDistiller::new(model(&good_reply()));
843        let existing = vec![ExistingSkill::new(
844            "deploy-runbook",
845            "An unrelated-sounding description about quarterly invoice reconciliation \
846             spreadsheets. Use when reconciling ledgers.",
847        )];
848        let out = d.distill(&complex_episode(), &existing).await.unwrap();
849        assert_eq!(
850            out,
851            Distillation::Skipped(SkipReason::NameTaken {
852                existing: "deploy-runbook".into()
853            })
854        );
855    }
856
857    #[tokio::test]
858    async fn a_chatty_user_accumulates_exactly_one_skill() {
859        // The scenario the whole duplicate check exists for: the same task,
860        // five times, against a real skills directory.
861        let root = tmp_root("chatty");
862        for i in 0..5 {
863            let d = SkillDistiller::new(model(&good_reply()));
864            let ep = Episode::new(
865                format!(
866                    "the user asked to deploy the marketing site to production and verify it is \
867                     live (request {i})"
868                ),
869                "built, ran deploy.sh, confirmed 200",
870            )
871            .with_tools(["read_file", "shell", "write_file", "shell", "web_fetch"])
872            .with_success(true);
873            let _ = d.distill_and_write(&ep, &root).await.unwrap();
874        }
875        let skills = existing_skills_in(&root);
876        assert_eq!(
877            skills.len(),
878            1,
879            "five near-identical runs must not mint five skills: {skills:#?}"
880        );
881        let _ = std::fs::remove_dir_all(&root);
882    }
883
884    #[tokio::test]
885    async fn distill_and_write_returns_none_when_gated_out() {
886        let root = tmp_root("gated");
887        let d = SkillDistiller::new(model(&good_reply()));
888        let ep = Episode::new("hi there friend", "hello").with_success(true);
889        assert!(d.distill_and_write(&ep, &root).await.unwrap().is_none());
890        assert!(existing_skills_in(&root).is_empty());
891        let _ = std::fs::remove_dir_all(&root);
892    }
893}