Skip to main content

harness_loop/experience/
episode.rs

1//! An `Episode` — one unit of experience: the situation faced, the tools used
2//! to handle it, and the outcome. Episodes are what the experience layer
3//! records and recalls.
4//!
5//! ## Two late additions, and why they are where they are
6//!
7//! `skills` and `success` were added for the closed learning loop (see
8//! [`crate::experience::SkillDistiller`] / [`crate::experience::SkillReviser`]): the distiller only
9//! fires on runs that *worked*, and the reviser only fires on a skill the run
10//! actually *followed*. Both are recorded per-run and are useless if they can't
11//! survive the memory round-trip.
12//!
13//! They therefore ride in [`Episode::render`] as extra lines placed **before**
14//! `Outcome:`, never after. `Outcome:` is the terminal field — [`Episode::parse`]
15//! takes everything from its prefix to end-of-text — so a field appended after
16//! it would be swallowed into the outcome string. Putting them before keeps the
17//! text format compatible in *both* directions: old text (no `Skills used:` /
18//! `Result:` lines) parses into `skills: []` / `success: None`, and text written
19//! by this version still parses correctly under the previous prefix-scanning
20//! parser. On the serde side both fields are `#[serde(default)]`, so episodes
21//! already sitting in a JSONL/SQLite memory deserialize unchanged.
22
23use serde::{Deserialize, Serialize};
24
25/// One remembered experience.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Episode {
28    /// What the agent was asked / the situation it faced (the recall key).
29    pub situation: String,
30    /// Names of the tools called while handling it, in first-seen order.
31    pub tools: Vec<String>,
32    /// How it turned out — the approach / final answer, summarized.
33    pub outcome: String,
34    /// Extra retrieval tags (beyond the automatic ones).
35    #[serde(default)]
36    pub tags: Vec<String>,
37    /// Names of the skills the run followed, if any. Populated by the host (or
38    /// by [`crate::experience::SkillUseTrace`]); the framework emits no skill-activation
39    /// event of its own, so nothing fills this automatically.
40    ///
41    /// This is the join key the reviser needs: "skill `deploy-runbook` was in
42    /// play when this run went wrong".
43    #[serde(default)]
44    pub skills: Vec<String>,
45    /// Did the run achieve what it was asked to? `None` = the host never said.
46    ///
47    /// Deliberately three-valued. Distillation treats "unknown" like "no": a
48    /// skill minted from a run nobody confirmed worked is exactly how bad
49    /// procedural memory is born.
50    #[serde(default)]
51    pub success: Option<bool>,
52}
53
54const SITUATION_PREFIX: &str = "Situation: ";
55const TOOLS_PREFIX: &str = "Tools used: ";
56const SKILLS_PREFIX: &str = "Skills used: ";
57const RESULT_PREFIX: &str = "Result: ";
58const OUTCOME_PREFIX: &str = "Outcome: ";
59
60impl Episode {
61    pub fn new(situation: impl Into<String>, outcome: impl Into<String>) -> Self {
62        Self {
63            situation: situation.into(),
64            tools: Vec::new(),
65            outcome: outcome.into(),
66            tags: Vec::new(),
67            skills: Vec::new(),
68            success: None,
69        }
70    }
71
72    pub fn with_tools<I, S>(mut self, tools: I) -> Self
73    where
74        I: IntoIterator<Item = S>,
75        S: Into<String>,
76    {
77        self.tools = tools.into_iter().map(Into::into).collect();
78        self
79    }
80
81    pub fn with_tags<I, S>(mut self, tags: I) -> Self
82    where
83        I: IntoIterator<Item = S>,
84        S: Into<String>,
85    {
86        self.tags = tags.into_iter().map(Into::into).collect();
87        self
88    }
89
90    /// Names of the skills the run followed. See [`Episode::skills`].
91    pub fn with_skills<I, S>(mut self, skills: I) -> Self
92    where
93        I: IntoIterator<Item = S>,
94        S: Into<String>,
95    {
96        self.skills = skills.into_iter().map(Into::into).collect();
97        self
98    }
99
100    /// Whether the run achieved what it was asked to. See [`Episode::success`].
101    pub fn with_success(mut self, success: bool) -> Self {
102        self.success = Some(success);
103        self
104    }
105
106    /// Convenience for the distiller's gate: `true` only when the host
107    /// explicitly said the run worked. Unknown counts as "no".
108    pub fn succeeded(&self) -> bool {
109        self.success == Some(true)
110    }
111
112    /// Natural-language rendering used as the searchable memory content.
113    /// Keyword/semantic backends index this text; [`Episode::parse`] reverses it.
114    ///
115    /// `Skills used:` / `Result:` are emitted only when set, and always *before*
116    /// `Outcome:` — see the module docs for why that ordering is load-bearing.
117    pub fn render(&self) -> String {
118        let tools = if self.tools.is_empty() {
119            "(none)".to_string()
120        } else {
121            self.tools.join(", ")
122        };
123        let mut s = format!(
124            "{SITUATION_PREFIX}{}\n{TOOLS_PREFIX}{tools}\n",
125            self.situation.trim(),
126        );
127        if !self.skills.is_empty() {
128            s.push_str(&format!("{SKILLS_PREFIX}{}\n", self.skills.join(", ")));
129        }
130        if let Some(ok) = self.success {
131            s.push_str(&format!(
132                "{RESULT_PREFIX}{}\n",
133                if ok { "success" } else { "failure" }
134            ));
135        }
136        s.push_str(&format!("{OUTCOME_PREFIX}{}", self.outcome.trim()));
137        s
138    }
139
140    /// Best-effort reconstruction from [`Episode::render`] output. Unknown /
141    /// malformed text yields `None`.
142    ///
143    /// Text written before `skills`/`success` existed parses fine: the two new
144    /// prefixes simply aren't found, and the fields come back empty/`None`.
145    pub fn parse(text: &str) -> Option<Episode> {
146        let situation = line_after(text, SITUATION_PREFIX)?;
147        let tools = comma_list(line_after(text, TOOLS_PREFIX).unwrap_or_default());
148        let skills = comma_list(line_after(text, SKILLS_PREFIX).unwrap_or_default());
149        let success = line_after(text, RESULT_PREFIX).and_then(|v| match v.trim() {
150            "success" => Some(true),
151            "failure" => Some(false),
152            _ => None,
153        });
154        // Outcome is the last field: take everything after its prefix.
155        let outcome = text
156            .find(OUTCOME_PREFIX)
157            .map(|i| text[i + OUTCOME_PREFIX.len()..].trim().to_string())
158            .unwrap_or_default();
159        Some(Episode {
160            situation,
161            tools,
162            outcome,
163            tags: Vec::new(),
164            skills,
165            success,
166        })
167    }
168}
169
170/// Split a rendered `a, b, c` list, treating the empty/`(none)` placeholder as
171/// no entries.
172fn comma_list(line: String) -> Vec<String> {
173    if line.trim().is_empty() || line.trim() == "(none)" {
174        return Vec::new();
175    }
176    line.split(',')
177        .map(|s| s.trim().to_string())
178        .filter(|s| !s.is_empty())
179        .collect()
180}
181
182/// Return the content of the first line starting with `prefix`.
183fn line_after(text: &str, prefix: &str) -> Option<String> {
184    text.lines()
185        .find(|l| l.starts_with(prefix))
186        .map(|l| l[prefix.len()..].trim().to_string())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn render_parse_roundtrip() {
195        let ep = Episode::new(
196            "user asked to deploy the site",
197            "ran deploy.sh, site is live",
198        )
199        .with_tools(["read_file", "shell", "shell"]);
200        let text = ep.render();
201        assert!(text.contains("Tools used: read_file, shell, shell"));
202        let back = Episode::parse(&text).unwrap();
203        assert_eq!(back.situation, "user asked to deploy the site");
204        assert_eq!(back.tools, vec!["read_file", "shell", "shell"]);
205        assert_eq!(back.outcome, "ran deploy.sh, site is live");
206    }
207
208    #[test]
209    fn parse_no_tools() {
210        let ep = Episode::new("simple question", "answered directly");
211        let back = Episode::parse(&ep.render()).unwrap();
212        assert!(back.tools.is_empty());
213    }
214
215    /// Text written by the pre-`skills`/`success` version of this crate — the
216    /// exact three-line shape sitting in every existing memory backend. It must
217    /// keep parsing, and the two new fields must come back empty rather than
218    /// poisoning the outcome.
219    #[test]
220    fn old_format_text_still_round_trips() {
221        const OLD: &str = "Situation: user asked to deploy the site\n\
222                           Tools used: read_file, shell, shell\n\
223                           Outcome: ran deploy.sh, site is live";
224        let back = Episode::parse(OLD).expect("legacy render must still parse");
225        assert_eq!(back.situation, "user asked to deploy the site");
226        assert_eq!(back.tools, vec!["read_file", "shell", "shell"]);
227        assert_eq!(back.outcome, "ran deploy.sh, site is live");
228        assert!(back.skills.is_empty(), "absent field → empty, not garbage");
229        assert_eq!(back.success, None, "absent field → unknown, not false");
230
231        // And re-rendering a legacy episode reproduces the legacy text byte for
232        // byte: no phantom `Skills used:`/`Result:` lines for old data.
233        assert_eq!(back.render(), OLD);
234    }
235
236    /// Old *serialized* episodes (JSON without the new keys) must deserialize.
237    #[test]
238    fn old_format_json_still_deserializes() {
239        let json = r#"{"situation":"s","tools":["a"],"outcome":"o","tags":[]}"#;
240        let ep: Episode = serde_json::from_str(json).unwrap();
241        assert!(ep.skills.is_empty());
242        assert_eq!(ep.success, None);
243    }
244
245    #[test]
246    fn skills_and_success_round_trip() {
247        let ep = Episode::new("ship the release", "tagged and pushed")
248            .with_tools(["shell", "read_file"])
249            .with_skills(["release-runbook", "changelog-writing"])
250            .with_success(false);
251        let text = ep.render();
252        // Outcome must stay terminal, otherwise `parse` swallows later fields.
253        assert!(text.trim_end().ends_with("tagged and pushed"));
254        let back = Episode::parse(&text).unwrap();
255        assert_eq!(back.skills, vec!["release-runbook", "changelog-writing"]);
256        assert_eq!(back.success, Some(false));
257        assert_eq!(back.outcome, "tagged and pushed");
258        assert!(!back.succeeded());
259    }
260
261    /// The new render must remain readable by the *old* parser, which located
262    /// fields by prefix scan and took everything after `Outcome: `. Re-implemented
263    /// here verbatim so the guarantee is tested, not merely asserted in a comment.
264    #[test]
265    fn new_format_is_readable_by_the_old_parser() {
266        let ep = Episode::new("ship the release", "tagged and pushed")
267            .with_tools(["shell"])
268            .with_skills(["release-runbook"])
269            .with_success(true);
270        let text = ep.render();
271
272        let legacy_situation = line_after(&text, "Situation: ").unwrap();
273        let legacy_tools = line_after(&text, "Tools used: ").unwrap();
274        let legacy_outcome = text
275            .find("Outcome: ")
276            .map(|i| text[i + "Outcome: ".len()..].trim().to_string())
277            .unwrap();
278        assert_eq!(legacy_situation, "ship the release");
279        assert_eq!(legacy_tools, "shell");
280        assert_eq!(legacy_outcome, "tagged and pushed");
281    }
282}