1use 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#[derive(Debug, Clone, PartialEq)]
55pub struct DistillPolicy {
56 pub require_success: bool,
59 pub min_tool_calls: usize,
62 pub min_distinct_tools: usize,
66 pub min_situation_chars: usize,
68 pub similarity_threshold: f32,
78 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#[derive(Debug, Clone, PartialEq)]
98#[non_exhaustive]
99pub enum SkipReason {
100 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 DuplicateSkill {
116 existing: String,
117 similarity: f32,
118 threshold: f32,
119 },
120 NameTaken {
124 existing: String,
125 },
126}
127
128#[derive(Debug, Clone, PartialEq)]
130pub enum GateDecision {
131 Distill,
133 Skip(SkipReason),
134}
135
136#[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 #[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#[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
193pub 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#[derive(Debug, Clone, PartialEq)]
219pub struct SkillDraft {
220 pub name: String,
222 pub description: String,
224 pub body: String,
226 pub source_situation: String,
229 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 pub fn to_skill_md(&self) -> String {
240 skillmd::render_skill_md(&self.manifest(), &self.body)
241 }
242
243 pub fn validate(&self) -> Result<(), DistillError> {
245 skillmd::validate_and_lint(&self.manifest(), &self.body).map_err(DistillError::Invalid)
246 }
247
248 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
258pub 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 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 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 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 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 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
432fn 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
469fn 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 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
530fn 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 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 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 #[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 #[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 assert_eq!(draft.name, "deploy-runbook");
704 harness_context::skills::validate_name(&draft.name).unwrap();
705
706 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 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 let ledger = RevisionLedger::from_manifest(loaded.manifest());
748 assert_eq!(ledger.revision, 0);
749 assert_eq!(ledger.origin.as_deref(), Some("distilled"));
750
751 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 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 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 #[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 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 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}