Skip to main content

newt_core/agentic/
recall.rs

1//! The `recall` tool seam — model-driven search over PAST conversations
2//! (Step 17.5, #246).
3//!
4//! Mirrors the `save_note` architecture ([`super::note_sink`]): the loop
5//! cannot name [`crate::store::ConversationStore`] directly without dragging
6//! persistence wiring into `newt-core::agentic`, so the seam is a minimal
7//! trait — the TUI injects [`StoreRecallSource`] (workspace-fenced by the
8//! store itself, current conversation excluded) and passes it through
9//! `ChatCtx` as `Option<&dyn RecallSource>`. `None` ⇒ the tool is not
10//! advertised and the loop never searches — eval/headless callers are
11//! unaffected.
12//!
13//! Design lineage (hermes-agent study, `session_search_tool.py`): coaching
14//! schema text ("USE THIS PROACTIVELY when the user references prior
15//! work"), snippets as the whole payload (no full content, no aux-LLM
16//! recaps), and the 17.3 sanitizer pre-flight so a query of pure
17//! operators comes back as coaching, never as a loop-aborting error.
18
19use super::display::{print_tool_call, print_tool_output};
20use crate::store::SearchHit;
21
22/// Default hit count when the model omits `limit`.
23const RECALL_DEFAULT_LIMIT: usize = 5;
24/// Hard ceiling on hits per call — snippets ride back through the model's
25/// context, so the cap is a token-budget guard (18.1), not a search knob.
26const RECALL_MAX_LIMIT: usize = 10;
27
28/// Read-only search over PAST conversations behind the `recall` tool.
29///
30/// Object-safe and shareable (the loop holds `&dyn RecallSource`; `Sync`
31/// because the borrow crosses `.await` points). Implementations MUST be
32/// scoped to the active workspace and MUST exclude the conversation the
33/// model is currently in — what's said here is already in context, and a
34/// recall hit on it would teach the model to search instead of read.
35/// Hit order is the backend's bm25 rank (§6: never re-sorted by any
36/// timestamp — wall-clock is a display claim, not an ordering key).
37pub trait RecallSource: Send + Sync {
38    /// Return up to `limit` best-first hits for `query` (plain keywords;
39    /// the executor has already pre-flighted the 17.3 sanitizer).
40    fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>>;
41
42    /// Return up to `limit` of THIS conversation's most recent durable turns —
43    /// the deliberate OPPOSITE of [`Self::search`]'s exclusion filter (#714).
44    ///
45    /// The `recall` contract refuses the active conversation (what's said here
46    /// is already in context); but after an interrupt + auto-resume the early
47    /// turns compaction cut from the live window survive ONLY in the store and
48    /// are reachable ONLY here. The `resume_context` tool uses this to let a
49    /// resumed thread read its own pre-interrupt work. Hits are oldest-first
50    /// (chronological), not bm25-ranked — this is a self-read, not a search.
51    ///
52    /// Default impl returns `Ok(vec![])` so existing mocks and headless callers
53    /// (which have no conversation store) compile and behave unchanged — only
54    /// [`StoreRecallSource`] overrides it.
55    fn this_conversation_recent(&self, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
56        let _ = limit;
57        Ok(Vec::new())
58    }
59}
60
61/// The canonical [`RecallSource`]: [`crate::store::ConversationStore::search`]
62/// (already fenced to the store's workspace key) minus the current
63/// conversation. The TUI constructs one per turn next to its `NoteSink`.
64pub struct StoreRecallSource<'a> {
65    store: &'a crate::store::ConversationStore,
66    current_conversation_id: &'a str,
67}
68
69/// Extra hits fetched beyond `limit` before the current conversation's own
70/// turns are filtered out. If the current conversation holds more than this
71/// many hits ranked above the cut, fewer than `limit` external hits return —
72/// accepted: those would be the worst-ranked matches anyway.
73const EXCLUSION_FETCH_HEADROOM: usize = 20;
74
75impl<'a> StoreRecallSource<'a> {
76    /// `current_conversation_id` is the conversation the model is in right
77    /// now — its turns never appear in results (that's what context is for).
78    pub fn new(
79        store: &'a crate::store::ConversationStore,
80        current_conversation_id: &'a str,
81    ) -> Self {
82        Self {
83            store,
84            current_conversation_id,
85        }
86    }
87}
88
89impl RecallSource for StoreRecallSource<'_> {
90    fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
91        // Over-fetch, drop the current conversation, truncate. bm25 order is
92        // preserved end-to-end (§6 discipline: the store's rank IS the
93        // ordering; nothing here re-sorts).
94        let hits = self
95            .store
96            .search(query, limit.saturating_add(EXCLUSION_FETCH_HEADROOM))?;
97        Ok(hits
98            .into_iter()
99            .filter(|h| h.conversation_id != self.current_conversation_id)
100            .take(limit)
101            .collect())
102    }
103
104    fn this_conversation_recent(&self, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
105        // The OPPOSITE of `search`'s filter (#714): load THIS conversation's own
106        // record (the same path `/conversation restore` uses) and hand back its
107        // last `limit` turns. On resume these are the pre-interrupt turns
108        // compaction cut from the live window — durable in the store, refused by
109        // `recall` by design, reachable only here.
110        if limit == 0 {
111            return Ok(Vec::new());
112        }
113        let record = self.store.load(self.current_conversation_id)?;
114        let start = record.turns.len().saturating_sub(limit);
115        Ok(record
116            .turns
117            .iter()
118            .enumerate()
119            .skip(start)
120            .map(|(i, turn)| SearchHit {
121                conversation_id: record.id.clone(),
122                title: record.title.clone(),
123                // Turns load without their §6 per-writer seq, so the 1-based
124                // position is the ordering label shown to the model. This is a
125                // self-read, not a recall rank — no bm25 score applies.
126                seq: (i + 1) as i64,
127                snippet: recent_turn_snippet(&turn.user, &turn.assistant),
128                rank: 0.0,
129            })
130            .collect())
131    }
132}
133
134/// Per-field char cap when compacting a turn into a `resume_context` snippet
135/// (#714) — a long turn must not blow the model's send budget on resume.
136const RESUME_TURN_FIELD_CAP: usize = 600;
137
138/// Compact one turn (the user ask + the assistant reply) into a single readable
139/// snippet for the `resume_context` self-read (#714): whitespace flattened (turn
140/// text is multi-line) and each field length-capped.
141fn recent_turn_snippet(user: &str, assistant: &str) -> String {
142    format!(
143        "you: {}\n    me: {}",
144        flatten_and_cap(user, RESUME_TURN_FIELD_CAP),
145        flatten_and_cap(assistant, RESUME_TURN_FIELD_CAP),
146    )
147}
148
149/// Collapse internal whitespace to single spaces and truncate to `cap` chars
150/// with a `[…]` marker — shared by [`recent_turn_snippet`].
151fn flatten_and_cap(s: &str, cap: usize) -> String {
152    let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
153    if flat.chars().count() > cap {
154        let head: String = flat.chars().take(cap).collect();
155        format!("{head}[…]")
156    } else {
157        flat
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Tool schema
163// ---------------------------------------------------------------------------
164
165/// The model-facing contract for `recall` — coaching text for a small local
166/// LLM: what it searches (PAST conversations, this workspace), when to reach
167/// for it (user references prior work), when NOT to (already in context),
168/// and how to query (plain keywords, not boolean/FTS syntax). Kept tight:
169/// schema tokens ride in every request (18.1).
170const RECALL_DESCRIPTION: &str =
171    "Search PAST conversations in this workspace. The current conversation is \
172     never searched — what was said here is already in your context. Use this \
173     when the user references earlier work ('like we did before', 'that bug \
174     we fixed', 'where did we leave off') or resumes a topic you don't see in \
175     context. Do NOT use it for information already in this conversation. \
176     Query with plain keywords ('tokio panic retry'), not boolean operators \
177     or quotes. Each result is a conversation id, title, and snippet with the \
178     match marked «like this»; the user can reopen one with \
179     /conversation restore <id>.";
180
181/// The `recall` tool definition. NOT part of [`super::tool_definitions`]:
182/// the loop advertises it only when a [`RecallSource`] is present, so
183/// headless / eval callers (which pass `recall_source: None`) never see it.
184pub fn recall_tool_definition() -> serde_json::Value {
185    serde_json::json!({
186        "type": "function",
187        "function": {
188            "name": "recall",
189            "description": RECALL_DESCRIPTION,
190            "parameters": {
191                "type": "object",
192                "properties": {
193                    "query": {
194                        "type": "string",
195                        "description": "Plain keywords describing what to find, e.g. \
196                                        'fts5 sanitizer tests' — no boolean or quote syntax"
197                    },
198                    "limit": {
199                        "type": "integer",
200                        "description": "Max matches to return, 1-10 (default 5)"
201                    }
202                },
203                "required": ["query"]
204            }
205        }
206    })
207}
208
209// ---------------------------------------------------------------------------
210// Dispatch
211// ---------------------------------------------------------------------------
212
213/// Execute one `recall` call against the source and return the result text
214/// fed back to the model.
215///
216/// Outcome contract (every branch is a *tool result*, never a loop abort):
217/// - hits → one block per hit: short id, title, `seq N`, snippet with the
218///   FTS5 `>>>`/`<<<` markers rewritten to `«`/`»` (the 17.4 convention);
219/// - a query the 17.3 sanitizer rejects → "no searchable terms" coaching;
220/// - zero hits → "no matches…" (never an empty string);
221/// - a real backend failure → `error: …`, verbatim, like every other tool.
222pub(crate) fn execute_recall(
223    args: &serde_json::Value,
224    source: &dyn RecallSource,
225    color: bool,
226    tool_output_lines: usize,
227) -> String {
228    let query = args["query"].as_str().unwrap_or("").trim();
229    // Absent / non-integer limit → default; present → clamped to [1, max].
230    let limit = args["limit"]
231        .as_u64()
232        .map(|l| {
233            usize::try_from(l)
234                .unwrap_or(RECALL_MAX_LIMIT)
235                .clamp(1, RECALL_MAX_LIMIT)
236        })
237        .unwrap_or(RECALL_DEFAULT_LIMIT);
238
239    print_tool_call("recall", query, color);
240
241    if query.is_empty() {
242        return "error: recall requires `query` — plain keywords describing what to find"
243            .to_string();
244    }
245    // Pre-flight the 17.3 sanitizer so an all-syntax query renders as
246    // coaching the model can act on, not an error that ends the turn.
247    if crate::store::sanitize_fts5_query(query).is_err() {
248        let out = format!(
249            "no searchable terms in {query:?} — every term was search syntax or \
250             punctuation; try plain keywords (e.g. 'tokio panic retry')"
251        );
252        print_tool_output(&out, tool_output_lines, color);
253        return out;
254    }
255
256    let hits = match source.search(query, limit) {
257        Ok(hits) => hits,
258        Err(e) => return format!("error: {e}"),
259    };
260    if hits.is_empty() {
261        // #714: recall structurally excludes the CURRENT conversation, so on a
262        // freshly-resumed thin context this dead-ends on the one conversation
263        // that matters. Append the redirect off the dead-end. (Must keep the
264        // "no matches in past conversations" prefix — `classify_phantom_reach`
265        // keys the #717 telemetry on it.)
266        let out = format!(
267            "no matches in past conversations for {query:?} — try different keywords. \
268             To recover THIS conversation's own earlier work, call resume_context."
269        );
270        print_tool_output(&out, tool_output_lines, color);
271        return out;
272    }
273
274    let mut out = format!(
275        "{} match(es) in past conversations (best first):",
276        hits.len()
277    );
278    for hit in &hits {
279        let title = hit.title.trim();
280        let title = if title.is_empty() {
281            "(untitled)"
282        } else {
283            title
284        };
285        out.push_str(&format!(
286            "\n{}  {}  ·  seq {}\n    {}",
287            short_id(&hit.conversation_id),
288            title,
289            hit.seq,
290            readable_snippet(&hit.snippet),
291        ));
292    }
293    print_tool_output(&out, tool_output_lines, color);
294    out
295}
296
297/// First 12 characters of a conversation id — the 17.4 convention: enough
298/// `{unix_nanos}` digits for 10ms granularity, and `resolve_id` accepts any
299/// unique prefix, so the short id pastes into `/conversation restore`.
300fn short_id(id: &str) -> &str {
301    id.get(..12).unwrap_or(id)
302}
303
304/// Make a raw FTS5 snippet read naturally to a model: collapse internal
305/// whitespace (turn text is multi-line) and rewrite the store's `>>>`/`<<<`
306/// match markers to `«`/`»` — the same convention 17.4's `/recall` renders
307/// for humans, so the model and the user see identical highlights.
308fn readable_snippet(snippet: &str) -> String {
309    snippet
310        .split_whitespace()
311        .collect::<Vec<_>>()
312        .join(" ")
313        .replace(">>>", "«")
314        .replace("<<<", "»")
315}
316
317// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322pub(crate) mod tests {
323    use super::*;
324    use std::sync::Mutex;
325
326    /// Scriptable mock: records every `(query, limit)` call, serves canned
327    /// hits (truncated to `limit`), or a canned error. Shared with the
328    /// dispatch tests in `agentic::tools`.
329    #[derive(Default)]
330    pub(crate) struct MockSource {
331        pub calls: Mutex<Vec<(String, usize)>>,
332        pub hits: Vec<SearchHit>,
333        pub fail_with: Option<String>,
334    }
335
336    impl RecallSource for MockSource {
337        fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
338            self.calls.lock().unwrap().push((query.to_string(), limit));
339            match &self.fail_with {
340                Some(e) => Err(anyhow::anyhow!("{e}")),
341                None => Ok(self.hits.iter().take(limit).cloned().collect()),
342            }
343        }
344    }
345
346    pub(crate) fn hit(id: &str, title: &str, seq: i64, snippet: &str) -> SearchHit {
347        SearchHit {
348            conversation_id: id.to_string(),
349            title: title.to_string(),
350            seq,
351            snippet: snippet.to_string(),
352            rank: -1.0,
353        }
354    }
355
356    // -- schema text: the model-facing coaching ------------------------------
357
358    #[test]
359    fn schema_says_past_conversations_and_excludes_current() {
360        let def = recall_tool_definition();
361        let desc = def["function"]["description"].as_str().unwrap();
362        assert!(desc.contains("Search PAST conversations in this workspace"));
363        assert!(
364            desc.contains("The current conversation is never searched"),
365            "got: {desc}"
366        );
367        assert!(desc.contains("already in your context"), "got: {desc}");
368    }
369
370    #[test]
371    fn schema_coaches_when_to_use_and_when_not() {
372        let def = recall_tool_definition();
373        let desc = def["function"]["description"].as_str().unwrap();
374        assert!(desc.contains("'like we did before'"), "got: {desc}");
375        assert!(desc.contains("'where did we leave off'"), "got: {desc}");
376        assert!(
377            desc.contains("Do NOT use it for information already in this conversation"),
378            "got: {desc}"
379        );
380    }
381
382    #[test]
383    fn schema_coaches_plain_keywords_over_fts_syntax() {
384        let def = recall_tool_definition();
385        let desc = def["function"]["description"].as_str().unwrap();
386        assert!(desc.contains("plain keywords"), "got: {desc}");
387        assert!(
388            desc.contains("not boolean operators or quotes"),
389            "got: {desc}"
390        );
391    }
392
393    #[test]
394    fn schema_shape_query_required_limit_optional() {
395        let def = recall_tool_definition();
396        assert_eq!(def["function"]["name"], "recall");
397        let required: Vec<&str> = def["function"]["parameters"]["required"]
398            .as_array()
399            .unwrap()
400            .iter()
401            .filter_map(|v| v.as_str())
402            .collect();
403        assert_eq!(required, vec!["query"]);
404        let props = &def["function"]["parameters"]["properties"];
405        assert!(props["query"].is_object());
406        assert_eq!(props["limit"]["type"], "integer");
407    }
408
409    // -- dispatch ------------------------------------------------------------
410
411    #[test]
412    fn happy_path_formats_short_id_title_seq_and_markers() {
413        let source = MockSource {
414            hits: vec![
415                hit(
416                    "1748563200123-aaaa-bbbb",
417                    "fixing the sanitizer",
418                    4,
419                    "…the >>>sanitizer<<< drops dangling\noperators…",
420                ),
421                hit("1748563200456-cccc-dddd", "  ", 2, ">>>sanitizer<<< port"),
422            ],
423            ..Default::default()
424        };
425        let out = execute_recall(
426            &serde_json::json!({"query": "sanitizer"}),
427            &source,
428            false,
429            20,
430        );
431        assert!(
432            out.starts_with("2 match(es) in past conversations (best first):"),
433            "got: {out}"
434        );
435        // Short id (12 chars), title, seq — one header per hit.
436        assert!(
437            out.contains("174856320012  fixing the sanitizer  ·  seq 4"),
438            "got: {out}"
439        );
440        // Markers converted per the 17.4 convention; whitespace flattened.
441        assert!(
442            out.contains("«sanitizer» drops dangling operators"),
443            "got: {out}"
444        );
445        assert!(!out.contains(">>>"), "raw FTS5 markers leaked: {out}");
446        // Empty title falls back, hit still rendered.
447        assert!(out.contains("(untitled)  ·  seq 2"), "got: {out}");
448        // Default limit reached the source.
449        assert_eq!(
450            *source.calls.lock().unwrap(),
451            vec![("sanitizer".to_string(), 5)]
452        );
453    }
454
455    #[test]
456    fn limit_is_defaulted_and_clamped() {
457        let source = MockSource::default();
458        // Absent → default 5 (asserted above too); oversized → max 10; zero → 1.
459        execute_recall(
460            &serde_json::json!({"query": "x", "limit": 25}),
461            &source,
462            false,
463            20,
464        );
465        execute_recall(
466            &serde_json::json!({"query": "x", "limit": 0}),
467            &source,
468            false,
469            20,
470        );
471        execute_recall(&serde_json::json!({"query": "x"}), &source, false, 20);
472        let limits: Vec<usize> = source.calls.lock().unwrap().iter().map(|c| c.1).collect();
473        assert_eq!(limits, vec![10, 1, 5]);
474    }
475
476    #[test]
477    fn sanitizer_rejected_query_is_coaching_not_error() {
478        let source = MockSource::default();
479        let out = execute_recall(&serde_json::json!({"query": "AND (*)"}), &source, false, 20);
480        assert!(out.contains("no searchable terms"), "got: {out}");
481        assert!(out.contains("plain keywords"), "got: {out}");
482        assert!(!out.starts_with("error:"), "must not abort-shape: {out}");
483        assert!(
484            source.calls.lock().unwrap().is_empty(),
485            "rejected query must never reach the backend"
486        );
487    }
488
489    #[test]
490    fn zero_hits_says_no_matches_never_empty() {
491        let source = MockSource::default();
492        let out = execute_recall(
493            &serde_json::json!({"query": "quetzalcoatl"}),
494            &source,
495            false,
496            20,
497        );
498        assert!(out.contains("no matches"), "got: {out}");
499        assert!(!out.is_empty());
500        // The prefix `classify_phantom_reach` keys on is preserved (#717).
501        assert!(
502            out.starts_with("no matches in past conversations"),
503            "got: {out}"
504        );
505        // #714: the dead-end now redirects to the self-recovery tool.
506        assert!(out.contains("resume_context"), "got: {out}");
507    }
508
509    #[test]
510    fn this_conversation_recent_default_impl_is_empty() {
511        // #714: the trait's default impl returns empty so existing mocks /
512        // headless callers (no conversation store) compile + behave unchanged.
513        let source = MockSource {
514            hits: vec![hit("conv-a", "t", 1, "snip")],
515            ..Default::default()
516        };
517        assert!(
518            source.this_conversation_recent(5).unwrap().is_empty(),
519            "default impl ignores `hits` and returns empty"
520        );
521    }
522
523    #[test]
524    fn recent_turn_snippet_flattens_and_caps() {
525        // #714: multi-line turn text collapses to one line per field.
526        let s = recent_turn_snippet("fix\nthe   bug", "done\nshipping it");
527        assert_eq!(s, "you: fix the bug\n    me: done shipping it");
528        // Over-long fields truncate with the […] marker.
529        let long = recent_turn_snippet(&"x".repeat(RESUME_TURN_FIELD_CAP + 50), "ok");
530        assert!(long.contains("[…]"), "got: {long}");
531        assert!(long.contains("me: ok"), "got: {long}");
532    }
533
534    #[test]
535    fn missing_query_is_a_clear_error() {
536        let source = MockSource::default();
537        let out = execute_recall(&serde_json::json!({}), &source, false, 20);
538        assert!(out.contains("requires `query`"), "got: {out}");
539        assert!(source.calls.lock().unwrap().is_empty());
540    }
541
542    #[test]
543    fn backend_failure_surfaces_as_tool_error_text() {
544        let source = MockSource {
545            fail_with: Some("database is on fire".to_string()),
546            ..Default::default()
547        };
548        let out = execute_recall(
549            &serde_json::json!({"query": "anything"}),
550            &source,
551            false,
552            20,
553        );
554        assert_eq!(out, "error: database is on fire");
555    }
556}