Skip to main content

mecha_core/tool/
recall.rs

1//! Recall: search this conversation's full recorded history.
2//!
3//! Compaction trades the middle of a transcript for a summary, and a summary
4//! preserves what the summariser thought mattered. When the run later needs a
5//! detail the summary dropped — the exact figure a tool returned twenty turns
6//! ago, the wording of an instruction — its options today are to re-run the
7//! tool or re-live the whole stretch, which is the loop the post-compaction
8//! guard exists to stop. Recall gives that moment somewhere to go: the session
9//! transcript already holds everything the conversation ever contained, so the
10//! model can look the detail up instead of reconstructing it.
11//!
12//! Two properties make this safe to hand to the model:
13//!
14//! - **It is taint-neutral by construction.** Everything in the transcript
15//!   entered *this* conversation once already, and taint is a property of the
16//!   conversation — recorded when the content arrived, merged back on resume,
17//!   never un-armed by compaction. Re-surfacing recorded content therefore
18//!   cannot change what the interlock knows, which is why the tool declares no
19//!   capabilities and its output is never marked `from_outside`: the bytes may
20//!   include third-party text, but this result came from our own store, and
21//!   the arrival that mattered was already accounted.
22//! - **The transcript path is the operator's, never the model's.** It is fixed
23//!   at registration to the session this conversation is recorded in, so there
24//!   is no path argument to resolve and no way to point the tool at another
25//!   session — recall over a *different* conversation's transcript would
26//!   re-surface content whose taint lives on a conversation that is not this
27//!   one, which is exactly the laundering the fixed path forecloses. Register
28//!   it only on the conversation the transcript records.
29//!
30//! Coverage: everything from earlier runs — every prior chat turn, every
31//! previous firing — which for long-lived sessions is precisely what
32//! compaction removes. Turns a *mid-run* compaction replaced reach the file
33//! too: the loop keeps each pre-rewrite state on the conversation
34//! ([`Conversation::rewritten`]) and `Session::record_run` walks them at run
35//! end. The one thing the corpus lags on is the current run itself —
36//! recording happens when it finishes — and those turns are the ones still
37//! in context, so the lag costs recall nothing it was for.
38//!
39//! [`Conversation::rewritten`]: crate::agent::Conversation
40
41use super::{Capabilities, Tool, ToolCtx, ToolOutput};
42use crate::message::{Block, Message, Role};
43use crate::session::Record;
44use anyhow::Result;
45use async_trait::async_trait;
46use serde_json::{json, Value};
47use std::collections::HashSet;
48use std::path::PathBuf;
49
50/// Matches returned per call unless the model asks for fewer. Enough to be
51/// useful, small enough that the interesting case — one needle — stays
52/// readable; the turn's output budget (and the spill behind it) still caps
53/// the pathological query.
54const DEFAULT_MAX_MATCHES: usize = 20;
55
56/// Lines of context on each side of a matching line.
57const CONTEXT_LINES: usize = 2;
58
59pub struct Recall {
60    transcript: PathBuf,
61}
62
63impl Recall {
64    pub fn new(transcript: PathBuf) -> Self {
65        Recall { transcript }
66    }
67}
68
69#[async_trait]
70impl Tool for Recall {
71    fn name(&self) -> &str {
72        "recall"
73    }
74
75    fn description(&self) -> &str {
76        "Search this conversation's full recorded history — including turns that were \
77         summarized away by compaction — for a case-insensitive literal string. Use it when \
78         an earlier detail (a value a tool returned, an instruction's exact wording) is no \
79         longer in context: searching the record is cheaper and more faithful than re-running \
80         the tool or reconstructing from memory. Returns matching lines with surrounding \
81         context, oldest first."
82    }
83
84    fn input_schema(&self) -> Value {
85        json!({
86            "type": "object",
87            "properties": {
88                "query": {
89                    "type": "string",
90                    "description": "Case-insensitive literal text to search for. Not a regex."
91                },
92                "max_matches": {
93                    "type": "integer",
94                    "description": "Maximum matching blocks to return (default 20)."
95                }
96            },
97            "required": ["query"]
98        })
99    }
100
101    fn read_only(&self) -> bool {
102        true
103    }
104
105    fn capabilities(&self) -> Capabilities {
106        // Deliberately none — see the module docs. The transcript's content
107        // already entered this conversation, and its taint entered with it.
108        Capabilities::default()
109    }
110
111    async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
112        let query = match input.get("query").and_then(Value::as_str) {
113            Some(q) if !q.trim().is_empty() => q.to_string(),
114            _ => {
115                return Ok(ToolOutput::err(
116                    "missing or empty required argument `query`",
117                ))
118            }
119        };
120        let max_matches = input
121            .get("max_matches")
122            .and_then(Value::as_u64)
123            .map(|n| n.max(1) as usize)
124            .unwrap_or(DEFAULT_MAX_MATCHES);
125
126        let text = match tokio::fs::read_to_string(&self.transcript).await {
127            Ok(t) => t,
128            Err(e) => {
129                return Ok(ToolOutput::err(format!(
130                    "cannot read the session transcript ({e}); this conversation may not \
131                     be recording, in which case there is no history beyond what is in \
132                     context"
133                )))
134            }
135        };
136
137        let messages = every_message_ever(&text);
138        let (rendered, matched, capped) = search(&messages, &query, max_matches);
139
140        if matched == 0 {
141            return Ok(ToolOutput::ok(format!(
142                "no matches for {query:?} in {} recorded messages. The record covers \
143                 completed runs of this session; the current run's turns are still in \
144                 context rather than in the record.",
145                messages.len()
146            )));
147        }
148
149        let mut out = format!(
150            "{matched} matching block(s) for {query:?} across {} recorded messages, \
151             oldest first:\n\n{rendered}",
152            messages.len()
153        );
154        if capped > 0 {
155            out.push_str(&format!(
156                "\n[{capped} more matching block(s) not shown — narrow the query, or \
157                 raise max_matches]"
158            ));
159        }
160        Ok(ToolOutput::ok(out))
161    }
162}
163
164/// Every message the conversation ever contained, in first-seen order.
165///
166/// `Message` records are the append-only common case. A `Rewrite` record is a
167/// compaction (or eviction, or thinning) replacing the list in place — for
168/// *loading* a session the replacement is the truth, but for recall the whole
169/// point is what the replacement dropped, so its messages are unioned in
170/// rather than substituted: anything new (the summary, an edited result)
171/// joins the corpus, anything already seen is skipped. Malformed lines are
172/// skipped exactly as [`crate::session::Session::load`] skips them — a
173/// truncated final line is the normal residue of a killed process.
174fn every_message_ever(transcript: &str) -> Vec<Message> {
175    let mut seen = HashSet::new();
176    let mut all = Vec::new();
177    let mut admit = |m: Message, all: &mut Vec<Message>| {
178        // Equality via the serialized form: `Message` is `PartialEq` but not
179        // `Hash`, and the serialization is already the file's own currency.
180        if let Ok(key) = serde_json::to_string(&m) {
181            if seen.insert(key) {
182                all.push(m);
183            }
184        }
185    };
186    for line in transcript.lines().filter(|l| !l.trim().is_empty()) {
187        match serde_json::from_str::<Record>(line) {
188            Ok(Record::Message(m)) => admit(m, &mut all),
189            Ok(Record::Rewrite { messages }) => {
190                for m in messages {
191                    admit(m, &mut all);
192                }
193            }
194            Ok(_) => {}
195            Err(e) => tracing::debug!(error = %e, "recall: skipping malformed transcript line"),
196        }
197    }
198    all
199}
200
201/// The searchable text of a block, with a label saying what kind of thing
202/// matched — a value found in a tool result and the same value found in the
203/// model's own thinking carry different weight, and the label is what lets
204/// the model tell them apart.
205fn block_text(block: &Block) -> (&'static str, String) {
206    match block {
207        Block::Text { text } => ("text", text.clone()),
208        Block::Thinking { text, .. } => ("thinking", text.clone()),
209        Block::ToolUse { name, input, .. } => ("tool_use", format!("{name} {input}")),
210        Block::ToolResult { content, .. } => ("tool_result", content.clone()),
211    }
212}
213
214fn role_name(role: &Role) -> &'static str {
215    match role {
216        Role::User => "user",
217        Role::Assistant => "assistant",
218    }
219}
220
221/// Search the corpus. Returns (rendered matches, matched-block count shown,
222/// matching blocks beyond the cap).
223fn search(messages: &[Message], query: &str, max_matches: usize) -> (String, usize, usize) {
224    let needle = query.to_lowercase();
225    let mut rendered = Vec::new();
226    let mut shown = 0usize;
227    let mut beyond = 0usize;
228
229    for (idx, message) in messages.iter().enumerate() {
230        for block in &message.content {
231            let (kind, text) = block_text(block);
232            let windows = matching_windows(&text, &needle);
233            if windows.is_empty() {
234                continue;
235            }
236            if shown >= max_matches {
237                beyond += 1;
238                continue;
239            }
240            shown += 1;
241            let lines: Vec<&str> = text.lines().collect();
242            let mut body = String::new();
243            for (start, end) in &windows {
244                if !body.is_empty() {
245                    body.push_str("  ⋮\n");
246                }
247                for line in &lines[*start..*end] {
248                    body.push_str("  ");
249                    body.push_str(line);
250                    body.push('\n');
251                }
252            }
253            rendered.push(format!(
254                "[message {idx} · {} · {kind}]\n{body}",
255                role_name(&message.role)
256            ));
257        }
258    }
259    (rendered.join("\n"), shown, beyond)
260}
261
262/// Half-open line ranges around each matching line, overlapping ranges
263/// merged so a cluster of hits reads as one excerpt instead of repeating
264/// itself.
265fn matching_windows(text: &str, lowercase_needle: &str) -> Vec<(usize, usize)> {
266    let lines: Vec<&str> = text.lines().collect();
267    let mut windows: Vec<(usize, usize)> = Vec::new();
268    for (i, line) in lines.iter().enumerate() {
269        if !line.to_lowercase().contains(lowercase_needle) {
270            continue;
271        }
272        let start = i.saturating_sub(CONTEXT_LINES);
273        let end = (i + CONTEXT_LINES + 1).min(lines.len());
274        match windows.last_mut() {
275            Some((_, prev_end)) if start <= *prev_end => *prev_end = end,
276            _ => windows.push((start, end)),
277        }
278    }
279    windows
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::message::Message;
286    use crate::session::{Record, SessionMeta};
287    use crate::tool::ToolCtx;
288
289    fn write_transcript(records: &[Record]) -> PathBuf {
290        let path =
291            std::env::temp_dir().join(format!("mecha-recall-{}.jsonl", uuid::Uuid::new_v4()));
292        let body: String = records
293            .iter()
294            .map(|r| serde_json::to_string(r).unwrap() + "\n")
295            .collect();
296        std::fs::write(&path, body).unwrap();
297        path
298    }
299
300    fn meta() -> Record {
301        Record::Meta(SessionMeta {
302            id: "recall-test".into(),
303            created_at: chrono::Utc::now(),
304            provider: "scripted".into(),
305            model: "none".into(),
306            workspace: std::env::temp_dir(),
307            title: None,
308        })
309    }
310
311    fn ctx() -> ToolCtx {
312        ToolCtx::default().with_workspace(std::env::temp_dir())
313    }
314
315    async fn run(tool: &Recall, input: Value) -> ToolOutput {
316        tool.call(input, &ctx()).await.unwrap()
317    }
318
319    /// The reason the tool exists: content a compaction rewrite dropped is
320    /// still found, because the corpus is the union of everything ever
321    /// recorded, not the post-rewrite state a `load` would return.
322    #[tokio::test]
323    async fn finds_content_a_rewrite_dropped() {
324        let dropped = Message::assistant(vec![Block::text("the magic number is 74656")]);
325        let path = write_transcript(&[
326            meta(),
327            Record::Message(Message::user("compute the magic number")),
328            Record::Message(dropped),
329            Record::Rewrite {
330                messages: vec![Message::user("[summary: a number was computed]")],
331            },
332        ]);
333        let tool = Recall::new(path);
334
335        let out = run(&tool, json!({"query": "74656"})).await;
336        assert!(!out.is_error);
337        assert!(
338            out.content.contains("74656"),
339            "dropped content not found: {}",
340            out.content
341        );
342        assert!(
343            out.content.contains("assistant"),
344            "match not attributed: {}",
345            out.content
346        );
347
348        // The rewrite's own additions are searchable too.
349        let out = run(&tool, json!({"query": "summary:"})).await;
350        assert!(out.content.contains("[summary:"));
351    }
352
353    /// A message recorded once and repeated verbatim inside a rewrite is one
354    /// corpus entry, not two — otherwise every compaction would double every
355    /// surviving message's matches.
356    #[tokio::test]
357    async fn a_rewritten_duplicate_matches_once() {
358        let kept = Message::user("the anchor phrase");
359        let path = write_transcript(&[
360            meta(),
361            Record::Message(kept.clone()),
362            Record::Rewrite {
363                messages: vec![kept],
364            },
365        ]);
366        let out = run(&Recall::new(path), json!({"query": "anchor phrase"})).await;
367        assert!(
368            out.content.starts_with("1 matching block(s)"),
369            "{}",
370            out.content
371        );
372    }
373
374    #[tokio::test]
375    async fn matching_is_case_insensitive_and_labelled_by_block_kind() {
376        let path = write_transcript(&[
377            meta(),
378            Record::Message(Message::tool_results(vec![Block::ToolResult {
379                tool_use_id: "t1".into(),
380                content: "Quarterly Total: $12,345".into(),
381                is_error: false,
382            }])),
383        ]);
384        let out = run(&Recall::new(path), json!({"query": "quarterly total"})).await;
385        assert!(!out.is_error);
386        assert!(out.content.contains("tool_result"), "{}", out.content);
387        assert!(out.content.contains("$12,345"));
388    }
389
390    #[tokio::test]
391    async fn zero_matches_reports_the_corpus_size_not_an_error() {
392        let path = write_transcript(&[meta(), Record::Message(Message::user("hello"))]);
393        let out = run(&Recall::new(path), json!({"query": "absent"})).await;
394        assert!(!out.is_error);
395        assert!(out.content.contains("no matches"));
396        assert!(out.content.contains("1 recorded messages"));
397    }
398
399    #[tokio::test]
400    async fn a_missing_transcript_is_an_expected_failure() {
401        let tool = Recall::new(std::env::temp_dir().join("mecha-recall-nonexistent.jsonl"));
402        let out = run(&tool, json!({"query": "anything"})).await;
403        assert!(out.is_error);
404        assert!(out.content.contains("not be recording"));
405    }
406
407    #[tokio::test]
408    async fn an_empty_query_is_refused() {
409        let path = write_transcript(&[meta()]);
410        let out = run(&Recall::new(path), json!({"query": "  "})).await;
411        assert!(out.is_error);
412    }
413
414    #[tokio::test]
415    async fn the_match_cap_reports_what_it_hid() {
416        let records: Vec<Record> = std::iter::once(meta())
417            .chain((0..5).map(|i| Record::Message(Message::user(format!("needle row {i}")))))
418            .collect();
419        let path = write_transcript(&records);
420        let out = run(
421            &Recall::new(path),
422            json!({"query": "needle", "max_matches": 2}),
423        )
424        .await;
425        assert!(
426            out.content.contains("2 matching block(s)"),
427            "{}",
428            out.content
429        );
430        assert!(
431            out.content.contains("3 more matching block(s)"),
432            "{}",
433            out.content
434        );
435    }
436}