Skip to main content

harness_loop/experience/
skill_use.rs

1//! `SkillUseTrace` — record which skills a run followed.
2//!
3//! [`crate::experience::SkillReviser`] needs to know that a bad run was following skill X.
4//! Nothing in the framework knows that today: skills reach the model as prompt
5//! text (a catalogue, then a body), and there is no `SkillActivated` lifecycle
6//! event to hook — unlike tools, which [`crate::experience::ToolTrace`] can capture from
7//! `PreToolUse`.
8//!
9//! So this is deliberately thin: a shared buffer with the same
10//! `snapshot`/`drain` shape as [`crate::experience::ToolTrace`], filled either directly by
11//! the host (which knows what it injected) or via `Event::Custom` for hosts that
12//! would rather emit a signal than thread a handle around. It is *not* an
13//! inference layer — guessing which skill an agent followed by scanning its
14//! output would mislabel episodes, and a mislabelled episode is worse than an
15//! unlabelled one: it revises the wrong skill.
16//!
17//! ```ignore
18//! let skills = SkillUseTrace::new();
19//! skills.note("deploy-runbook");                 // host injected this skill
20//! let loop_ = AgentLoop::new(model).with_hook(skills.hook());  // or emit events
21//! // …
22//! recorder.record_episode(
23//!     Episode::new(&task.description, outcome)
24//!         .with_success(ok)
25//!         .with_skills(skills.drain()),
26//! ).await;
27//! ```
28
29use harness_core::{Event, Hook, HookOutcome, World};
30use std::sync::{Arc, Mutex};
31
32/// The `Event::Custom` name [`SkillUseTrace`]'s hook listens for.
33pub const SKILL_ACTIVATED_EVENT: &str = "skill.activated";
34
35/// Shared, cloneable buffer of skill names, deduplicated, in first-use order.
36///
37/// Deduplicated because — unlike a tool trace, where repetition is signal about
38/// the *approach* — a skill re-read three times is still one skill, and
39/// `Episode::skills` is a set-membership question ("was X in play?").
40#[derive(Clone, Default)]
41pub struct SkillUseTrace {
42    skills: Arc<Mutex<Vec<String>>>,
43}
44
45impl SkillUseTrace {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Record that the run is following `name`. Idempotent.
51    pub fn note(&self, name: impl Into<String>) {
52        let name = name.into();
53        if name.trim().is_empty() {
54            return;
55        }
56        if let Ok(mut g) = self.skills.lock()
57            && !g.iter().any(|s| s == &name)
58        {
59            g.push(name);
60        }
61    }
62
63    /// The skills recorded so far.
64    pub fn snapshot(&self) -> Vec<String> {
65        self.skills.lock().map(|g| g.clone()).unwrap_or_default()
66    }
67
68    /// Take the recorded skills and reset the buffer (call after a run).
69    pub fn drain(&self) -> Vec<String> {
70        self.skills
71            .lock()
72            .map(|mut g| std::mem::take(&mut *g))
73            .unwrap_or_default()
74    }
75
76    /// A `Hook` that fills this buffer from
77    /// `Event::Custom { name: "skill.activated", data: {"skill": "<name>"} }`.
78    ///
79    /// `Event::Custom` is the framework's escape hatch for signals it doesn't
80    /// model natively, which is exactly what skill activation currently is.
81    /// Emitting one keeps the host's skill-injection code from having to hold a
82    /// `SkillUseTrace` handle.
83    pub fn hook(&self) -> Arc<dyn Hook> {
84        Arc::new(SkillUseHook {
85            skills: self.skills.clone(),
86        })
87    }
88}
89
90struct SkillUseHook {
91    skills: Arc<Mutex<Vec<String>>>,
92}
93
94impl Hook for SkillUseHook {
95    fn name(&self) -> &str {
96        "experience-skill-use-trace"
97    }
98    fn matches(&self, ev: &Event<'_>) -> bool {
99        matches!(ev, Event::Custom { name, .. } if *name == SKILL_ACTIVATED_EVENT)
100    }
101    fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
102        if let Event::Custom { data, .. } = ev {
103            // Accept `{"skill": "x"}`, `{"skills": ["x", "y"]}`, or a bare
104            // string — hosts emit whichever is convenient, and dropping the
105            // signal over its shape would lose the episode's only skill label.
106            let names: Vec<String> = match data {
107                serde_json::Value::String(s) => vec![s.clone()],
108                v => {
109                    let mut out = Vec::new();
110                    if let Some(s) = v.get("skill").and_then(|s| s.as_str()) {
111                        out.push(s.to_string());
112                    }
113                    if let Some(arr) = v.get("skills").and_then(|s| s.as_array()) {
114                        out.extend(arr.iter().filter_map(|i| i.as_str()).map(str::to_string));
115                    }
116                    out
117                }
118            };
119            for n in names {
120                self.note_locked(&n);
121            }
122        }
123        HookOutcome::Allow
124    }
125}
126
127impl SkillUseHook {
128    fn note_locked(&self, name: &str) {
129        if name.trim().is_empty() {
130            return;
131        }
132        if let Ok(mut g) = self.skills.lock()
133            && !g.iter().any(|s| s == name)
134        {
135            g.push(name.to_string());
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use serde_json::json;
144
145    #[test]
146    fn note_dedupes_and_drain_resets() {
147        let t = SkillUseTrace::new();
148        t.note("deploy-runbook");
149        t.note("deploy-runbook");
150        t.note("changelog-writing");
151        t.note("   ");
152        assert_eq!(t.snapshot(), vec!["deploy-runbook", "changelog-writing"]);
153        assert_eq!(t.drain().len(), 2);
154        assert!(t.snapshot().is_empty(), "drain resets");
155    }
156
157    #[test]
158    fn hook_captures_every_payload_shape() {
159        let t = SkillUseTrace::new();
160        let hook = t.hook();
161        let mut world = harness_context::default_world(std::env::temp_dir());
162        for data in [
163            json!({"skill": "a"}),
164            json!({"skills": ["b", "c"]}),
165            json!("d"),
166            json!({"skill": "a"}), // duplicate
167        ] {
168            hook.fire(
169                &Event::Custom {
170                    name: SKILL_ACTIVATED_EVENT,
171                    data: &data,
172                },
173                &mut world,
174            );
175        }
176        assert_eq!(t.snapshot(), vec!["a", "b", "c", "d"]);
177    }
178
179    #[test]
180    fn hook_ignores_other_custom_events() {
181        let t = SkillUseTrace::new();
182        let hook = t.hook();
183        let data = json!({"skill": "a"});
184        assert!(!hook.matches(&Event::Custom {
185            name: "something.else",
186            data: &data,
187        }));
188        assert!(hook.matches(&Event::Custom {
189            name: SKILL_ACTIVATED_EVENT,
190            data: &data,
191        }));
192    }
193}