Skip to main content

newt_core/agentic/
experiential.rs

1//! Experiential memory — the `experiential` context feature (Step 26.6a, #585).
2//!
3//! A session ledger of TASK OUTCOMES (what was attempted, how it turned out, and
4//! the lesson) kept ACROSS tasks: unlike the scratchpad it SURVIVES `/new`, so a
5//! later task can reuse a repair recipe or steer clear of a known dead end. A
6//! quality WRITE-GATE keeps the ledger high-signal — only substantive,
7//! outcome-bearing records are stored. Relevant experiences are retrieved by
8//! keyword overlap and injected as an `<experience>` block at the head of the
9//! turn (gated by the feature, like 26.3/26.4/26.5).
10//!
11//! Pure in-memory + deterministic (a `Vec`, keyword overlap — no clock, uuid, or
12//! embeddings), so the whole feature unit-tests with zero network/fs. Mirrors
13//! `scratchpad.rs`: a `&self` interior-mutability trait + an in-memory impl.
14
15use super::display::{print_tool_call, print_tool_output};
16use std::sync::Mutex;
17
18/// One recorded experience: a task, its outcome, and the lesson learned.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Experience {
21    pub task: String,
22    pub outcome: String,
23    pub lesson: String,
24}
25
26/// How many experiences to inject / recall per query (the keyword overlap is
27/// cheap, so a small fixed top_k needs no config knob).
28pub const EXPERIENCE_TOP_K: usize = 5;
29/// Minimum lesson length (chars) for the write-gate — a one-word "lesson" is
30/// noise that would dilute the injected block.
31pub(crate) const MIN_LESSON_CHARS: usize = 12;
32/// Per-field render cap (task/outcome) and lesson cap in the `<experience>` block.
33pub(crate) const EXPERIENCE_FIELD_CAP: usize = 240;
34pub(crate) const EXPERIENCE_LESSON_CAP: usize = 600;
35/// Whole-block char cap so a long ledger can never blow the send budget.
36pub(crate) const EXPERIENCE_TOTAL_CAP: usize = 4_000;
37
38/// Whether a candidate experience clears the quality write-gate (Step 26.6a): a
39/// non-empty task, a non-empty outcome, and a lesson with real substance
40/// (≥ [`MIN_LESSON_CHARS`]). Keeps the ledger worth its tokens.
41pub(crate) fn passes_write_gate(task: &str, outcome: &str, lesson: &str) -> bool {
42    !task.trim().is_empty()
43        && !outcome.trim().is_empty()
44        && lesson.trim().chars().count() >= MIN_LESSON_CHARS
45}
46
47/// Tokenize for keyword-overlap relevance: alphanumeric runs of ≥3 chars,
48/// lowercased. Deterministic, allocation-light.
49fn terms(s: &str) -> Vec<String> {
50    s.split(|c: char| !c.is_alphanumeric())
51        .filter(|t| t.chars().count() >= 3)
52        .map(str::to_lowercase)
53        .collect()
54}
55
56/// Count distinct query terms that appear in `text` (term-overlap score).
57fn overlap(query_terms: &[String], text: &str) -> usize {
58    let tterms = terms(text);
59    query_terms
60        .iter()
61        .filter(|q| tterms.iter().any(|t| t == *q))
62        .count()
63}
64
65/// A session store for recorded experiences (Step 26.6a). `&self` methods
66/// (interior mutability) so one shared `&dyn ExperienceStore` serves both the
67/// per-turn injection and the record/recall tools. SURVIVES `/new` (cross-task).
68pub trait ExperienceStore: Send + Sync {
69    /// Store an experience IFF it clears the write-gate; returns whether it did.
70    fn record(&self, task: &str, outcome: &str, lesson: &str) -> bool;
71    /// The `top_k` experiences most relevant to `query` by keyword overlap, ties
72    /// broken by recency (most-recent first). Empty when nothing overlaps.
73    fn relevant(&self, query: &str, top_k: usize) -> Vec<Experience>;
74    /// Number of experiences held (for `/context stats`).
75    fn count(&self) -> u64;
76    /// Total chars across all stored fields (for `/context stats`).
77    fn total_chars(&self) -> u64;
78    /// Drop all entries (session end; NOT called on `/new`).
79    fn clear(&self);
80}
81
82/// In-memory, session-scoped [`ExperienceStore`] — pure (no fs). A `Vec` in
83/// insertion order; relevance is computed on read, so the store stays a simple
84/// deterministic log (no clock/uuid) for stable tests.
85#[derive(Default)]
86pub struct SessionExperienceStore {
87    entries: Mutex<Vec<Experience>>,
88}
89
90impl ExperienceStore for SessionExperienceStore {
91    fn record(&self, task: &str, outcome: &str, lesson: &str) -> bool {
92        if !passes_write_gate(task, outcome, lesson) {
93            return false;
94        }
95        self.entries.lock().unwrap().push(Experience {
96            task: task.trim().to_string(),
97            outcome: outcome.trim().to_string(),
98            lesson: lesson.trim().to_string(),
99        });
100        true
101    }
102
103    fn relevant(&self, query: &str, top_k: usize) -> Vec<Experience> {
104        let qterms = terms(query);
105        if qterms.is_empty() || top_k == 0 {
106            return Vec::new();
107        }
108        let entries = self.entries.lock().unwrap();
109        // Score by overlap with the task + lesson; keep only positives.
110        let mut scored: Vec<(usize, usize, &Experience)> = entries
111            .iter()
112            .enumerate()
113            .map(|(i, e)| (overlap(&qterms, &format!("{} {}", e.task, e.lesson)), i, e))
114            .filter(|(s, _, _)| *s > 0)
115            .collect();
116        // Highest overlap first; ties → most-recent (higher index) first.
117        scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
118        scored
119            .into_iter()
120            .take(top_k)
121            .map(|(_, _, e)| e.clone())
122            .collect()
123    }
124
125    fn count(&self) -> u64 {
126        self.entries.lock().unwrap().len() as u64
127    }
128
129    fn total_chars(&self) -> u64 {
130        self.entries
131            .lock()
132            .unwrap()
133            .iter()
134            .map(|e| {
135                (e.task.chars().count() + e.outcome.chars().count() + e.lesson.chars().count())
136                    as u64
137            })
138            .sum()
139    }
140
141    fn clear(&self) {
142        self.entries.lock().unwrap().clear();
143    }
144}
145
146fn truncate(s: &str, cap: usize) -> String {
147    if s.chars().count() <= cap {
148        s.to_string()
149    } else {
150        let head: String = s.chars().take(cap).collect();
151        format!("{head}[…]")
152    }
153}
154
155/// Render the `<experience>` block of the relevant past experiences (Step
156/// 26.6a). `None` when none are relevant — the OFF/empty bit-for-bit guarantee
157/// (mirror `scratchpad::build_state_block`). Per-field and whole-block caps are
158/// applied here at injection.
159pub(crate) fn build_experience_block(
160    store: &dyn ExperienceStore,
161    query: &str,
162    top_k: usize,
163    total_cap: usize,
164) -> Option<String> {
165    let hits = store.relevant(query, top_k);
166    if hits.is_empty() {
167        return None;
168    }
169    let mut body = String::from("<experience>\n");
170    for e in &hits {
171        let piece = format!(
172            "- task: {}\n  outcome: {}\n  lesson: {}\n",
173            truncate(&e.task, EXPERIENCE_FIELD_CAP),
174            truncate(&e.outcome, EXPERIENCE_FIELD_CAP),
175            truncate(&e.lesson, EXPERIENCE_LESSON_CAP),
176        );
177        if body.chars().count() + piece.chars().count() + "</experience>".len() > total_cap {
178            body.push_str("[… more experience omitted to fit the budget …]\n");
179            break;
180        }
181        body.push_str(&piece);
182    }
183    body.push_str("</experience>");
184    Some(body)
185}
186
187/// TUI-facing entry: the relevant `<experience>` for `query` with the default
188/// budget cap (Step 26.6a). `None` when nothing is relevant.
189pub fn experience_block(store: &dyn ExperienceStore, query: &str, top_k: usize) -> Option<String> {
190    build_experience_block(store, query, top_k, EXPERIENCE_TOTAL_CAP)
191}
192
193// ---------------------------------------------------------------------------
194// Tool schemas (advertised only when the feature is on + a store is present)
195// ---------------------------------------------------------------------------
196
197/// `experience_record` tool definition.
198pub fn experience_record_tool_definition() -> serde_json::Value {
199    serde_json::json!({
200        "type": "function",
201        "function": {
202            "name": "experience_record",
203            "description": "Record a reusable lesson from a finished task — a repair recipe \
204                            that worked, a dead end to avoid, a gotcha. It persists ACROSS \
205                            tasks this session (it survives /new) and the relevant ones are \
206                            shown to you automatically on similar future tasks. Only \
207                            substantive lessons are kept (a real outcome + a lesson of a \
208                            sentence or so).",
209            "parameters": {
210                "type": "object",
211                "properties": {
212                    "task": { "type": "string", "description": "What was being attempted (a short phrase)." },
213                    "outcome": { "type": "string", "description": "How it turned out, e.g. 'fixed' / 'failed' / 'worked but slow'." },
214                    "lesson": { "type": "string", "description": "The reusable takeaway — specific enough to act on next time." }
215                },
216                "required": ["task", "outcome", "lesson"]
217            }
218        }
219    })
220}
221
222/// `experience_recall` tool definition.
223pub fn experience_recall_tool_definition() -> serde_json::Value {
224    serde_json::json!({
225        "type": "function",
226        "function": {
227            "name": "experience_recall",
228            "description": "Search your recorded experiences for ones relevant to a topic \
229                            (by keyword) — use it to check 'have I dealt with this before?' \
230                            when the auto-injected block doesn't already cover it.",
231            "parameters": {
232                "type": "object",
233                "properties": {
234                    "query": { "type": "string", "description": "The topic / task to recall experience for." }
235                },
236                "required": ["query"]
237            }
238        }
239    })
240}
241
242// ---------------------------------------------------------------------------
243// Executors (every branch returns a tool-result String, never a loop abort)
244// ---------------------------------------------------------------------------
245
246/// Execute an `experience_record` call (Step 26.6a) — write-gated.
247pub(crate) fn execute_experience_record(
248    args: &serde_json::Value,
249    store: &dyn ExperienceStore,
250    color: bool,
251    tool_output_lines: usize,
252) -> String {
253    let task = args["task"].as_str().unwrap_or("").trim();
254    let outcome = args["outcome"].as_str().unwrap_or("").trim();
255    let lesson = args["lesson"].as_str().unwrap_or("").trim();
256    print_tool_call("experience_record", task, color);
257    let out = if store.record(task, outcome, lesson) {
258        "recorded experience".to_string()
259    } else {
260        format!(
261            "not recorded — needs a non-empty task + outcome and a lesson of ≥{MIN_LESSON_CHARS} chars"
262        )
263    };
264    print_tool_output(&out, tool_output_lines, color);
265    out
266}
267
268/// Execute an `experience_recall` call (Step 26.6a).
269pub(crate) fn execute_experience_recall(
270    args: &serde_json::Value,
271    store: &dyn ExperienceStore,
272    top_k: usize,
273    color: bool,
274    tool_output_lines: usize,
275) -> String {
276    let query = args["query"].as_str().unwrap_or("").trim();
277    print_tool_call("experience_recall", query, color);
278    if query.is_empty() {
279        return "error: experience_recall requires a non-empty `query`".to_string();
280    }
281    let out = build_experience_block(store, query, top_k, EXPERIENCE_TOTAL_CAP)
282        .unwrap_or_else(|| "no relevant experience recorded yet".to_string());
283    print_tool_output(&out, tool_output_lines, color);
284    out
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn write_gate_filters_trivial_records() {
293        assert!(passes_write_gate(
294            "fix lints",
295            "fixed",
296            "run cargo fmt before clippy"
297        ));
298        assert!(
299            !passes_write_gate("", "fixed", "a real lesson here"),
300            "empty task"
301        );
302        assert!(
303            !passes_write_gate("t", "", "a real lesson here"),
304            "empty outcome"
305        );
306        assert!(
307            !passes_write_gate("t", "ok", "too short"),
308            "lesson < 12 chars"
309        );
310    }
311
312    #[test]
313    fn store_records_only_gated_and_tracks_stats() {
314        let s = SessionExperienceStore::default();
315        let (task, outcome, lesson) = ("parser", "fixed", "memoize the tokenizer here");
316        assert!(s.record(task, outcome, lesson));
317        assert!(!s.record("x", "y", "short"), "trivial rejected");
318        assert_eq!(s.count(), 1, "only the gated record stored");
319        // exact char accounting (chars().count(), so multibyte-safe)
320        let expected =
321            (task.chars().count() + outcome.chars().count() + lesson.chars().count()) as u64;
322        assert_eq!(s.total_chars(), expected);
323        s.clear();
324        assert_eq!(s.count(), 0);
325        assert_eq!(s.total_chars(), 0);
326    }
327
328    #[test]
329    fn relevant_ranks_by_overlap_then_recency() {
330        let s = SessionExperienceStore::default();
331        s.record(
332            "tokenizer perf",
333            "fixed",
334            "memoize tokenizer lookups for speed",
335        );
336        s.record(
337            "network retry",
338            "fixed",
339            "exponential backoff avoids thundering herd",
340        );
341        s.record(
342            "tokenizer crash",
343            "fixed",
344            "guard the tokenizer against empty input",
345        );
346        // a tokenizer query → the two tokenizer entries, recent first; not network
347        let hits = s.relevant("tokenizer problems", 5);
348        assert_eq!(hits.len(), 2, "only the two overlapping entries");
349        assert_eq!(
350            hits[0].task, "tokenizer crash",
351            "recency breaks the overlap tie"
352        );
353        assert!(hits.iter().all(|e| e.task != "network retry"));
354        // no overlap → empty; top_k 0 → empty
355        assert!(s.relevant("kubernetes yaml", 5).is_empty());
356        assert!(s.relevant("tokenizer", 0).is_empty());
357    }
358
359    #[test]
360    fn build_block_none_when_irrelevant_and_caps() {
361        let s = SessionExperienceStore::default();
362        assert_eq!(
363            build_experience_block(&s, "anything", 5, 4000),
364            None,
365            "empty store"
366        );
367        s.record(
368            "indexing",
369            "ok",
370            "build the index lazily on first use, then cache it",
371        );
372        // relevant → a rendered block
373        let block = build_experience_block(&s, "indexing strategy", 5, 4000).unwrap();
374        assert!(block.starts_with("<experience>\n") && block.ends_with("</experience>"));
375        assert!(block.contains("task: indexing") && block.contains("lesson: build the index"));
376        // irrelevant query → None even with entries
377        assert_eq!(build_experience_block(&s, "zzz", 5, 4000), None);
378        // total cap trips the omitted marker
379        for i in 0..40 {
380            s.record(
381                &format!("indexing task {i}"),
382                "ok",
383                &"index lessons ".repeat(20),
384            );
385        }
386        let capped = build_experience_block(&s, "indexing", 50, 300).unwrap();
387        assert!(
388            capped.chars().count() <= 300 + 80,
389            "total cap bounds the block"
390        );
391        assert!(capped.contains("omitted to fit"));
392    }
393
394    #[test]
395    fn executors_record_gate_and_recall() {
396        let s = SessionExperienceStore::default();
397        // record: gated accept / reject coaching
398        assert_eq!(
399            execute_experience_record(
400                &serde_json::json!({"task": "ci flake", "outcome": "fixed", "lesson": "pin the seed for the fuzz test"}),
401                &s,
402                false,
403                20
404            ),
405            "recorded experience"
406        );
407        assert!(execute_experience_record(
408            &serde_json::json!({"task": "x", "outcome": "y", "lesson": "short"}),
409            &s,
410            false,
411            20
412        )
413        .starts_with("not recorded"));
414        // recall: hit / miss / empty-query coaching
415        assert!(execute_experience_recall(
416            &serde_json::json!({"query": "ci flake seed"}),
417            &s,
418            5,
419            false,
420            20
421        )
422        .contains("<experience>"));
423        assert!(execute_experience_recall(
424            &serde_json::json!({"query": "unrelated topic"}),
425            &s,
426            5,
427            false,
428            20
429        )
430        .contains("no relevant experience"));
431        assert!(
432            execute_experience_recall(&serde_json::json!({}), &s, 5, false, 20)
433                .starts_with("error:")
434        );
435    }
436
437    #[test]
438    fn tool_definitions_shape() {
439        assert_eq!(
440            experience_record_tool_definition()["function"]["name"],
441            "experience_record"
442        );
443        assert_eq!(
444            experience_recall_tool_definition()["function"]["name"],
445            "experience_recall"
446        );
447    }
448}