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}