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 anyhow::Result;
44use async_trait::async_trait;
45use serde_json::{json, Value};
46use std::path::PathBuf;
47
48/// Matches returned per call unless the model asks for fewer. Enough to be
49/// useful, small enough that the interesting case — one needle — stays
50/// readable; the turn's output budget (and the spill behind it) still caps
51/// the pathological query.
52const DEFAULT_MAX_MATCHES: usize = 20;
53
54/// Lines of context on each side of a matching line.
55const CONTEXT_LINES: usize = 2;
56
57pub struct Recall {
58    transcript: PathBuf,
59}
60
61impl Recall {
62    pub fn new(transcript: PathBuf) -> Self {
63        Recall { transcript }
64    }
65}
66
67#[async_trait]
68impl Tool for Recall {
69    fn name(&self) -> &str {
70        "recall"
71    }
72
73    fn description(&self) -> &str {
74        "Search this conversation's full recorded history — including turns that were \
75         summarized away by compaction — for a case-insensitive literal string. Use it when \
76         an earlier detail (a value a tool returned, an instruction's exact wording) is no \
77         longer in context: searching the record is cheaper and more faithful than re-running \
78         the tool or reconstructing from memory. Returns matching lines with surrounding \
79         context, oldest first."
80    }
81
82    fn input_schema(&self) -> Value {
83        json!({
84            "type": "object",
85            "properties": {
86                "query": {
87                    "type": "string",
88                    "description": "Case-insensitive literal text to search for. Not a regex."
89                },
90                "max_matches": {
91                    "type": "integer",
92                    "description": "Maximum matching blocks to return (default 20)."
93                }
94            },
95            "required": ["query"]
96        })
97    }
98
99    fn read_only(&self) -> bool {
100        true
101    }
102
103    fn capabilities(&self) -> Capabilities {
104        // Deliberately none — see the module docs. The transcript's content
105        // already entered this conversation, and its taint entered with it.
106        Capabilities::default()
107    }
108
109    async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
110        let query = match input.get("query").and_then(Value::as_str) {
111            Some(q) if !q.trim().is_empty() => q.to_string(),
112            _ => {
113                return Ok(ToolOutput::err(
114                    "missing or empty required argument `query`",
115                ))
116            }
117        };
118        let max_matches = input
119            .get("max_matches")
120            .and_then(Value::as_u64)
121            .map(|n| n.max(1) as usize)
122            .unwrap_or(DEFAULT_MAX_MATCHES);
123
124        let text = match tokio::fs::read_to_string(&self.transcript).await {
125            Ok(t) => t,
126            Err(e) => {
127                return Ok(ToolOutput::err(format!(
128                    "cannot read the session transcript ({e}); this conversation may not \
129                     be recording, in which case there is no history beyond what is in \
130                     context"
131                )))
132            }
133        };
134
135        let messages = crate::session::Session::messages_ever(&text);
136        let (rendered, matched, capped) = search(&messages, &query, max_matches);
137
138        if matched == 0 {
139            return Ok(ToolOutput::ok(format!(
140                "no matches for {query:?} in {} recorded messages. The record covers \
141                 completed runs of this session; the current run's turns are still in \
142                 context rather than in the record.",
143                messages.len()
144            )));
145        }
146
147        let mut out = format!(
148            "{matched} matching block(s) for {query:?} across {} recorded messages, \
149             oldest first:\n\n{rendered}",
150            messages.len()
151        );
152        if capped > 0 {
153            out.push_str(&format!(
154                "\n[{capped} more matching block(s) not shown — narrow the query, or \
155                 raise max_matches]"
156            ));
157        }
158        Ok(ToolOutput::ok(out))
159    }
160}
161
162/// The searchable text of a block, with a label saying what kind of thing
163/// matched — a value found in a tool result and the same value found in the
164/// model's own thinking carry different weight, and the label is what lets
165/// the model tell them apart.
166fn block_text(block: &Block) -> (&'static str, String) {
167    match block {
168        Block::Text { text } => ("text", text.clone()),
169        Block::Thinking { text, .. } => ("thinking", text.clone()),
170        Block::ToolUse { name, input, .. } => ("tool_use", format!("{name} {input}")),
171        Block::ToolResult { content, .. } => ("tool_result", content.clone()),
172        // **The filename, never the payload.** Base64 is a haystack of every
173        // alphanumeric substring there is, so returning `data` would make a
174        // one-letter query match every image in the transcript and print a
175        // megabyte of it back into the context this tool exists to protect.
176        // What a person searching for a screenshot actually types is its
177        // name.
178        Block::Image {
179            media_type, source, ..
180        } => (
181            "image",
182            Block::image_placeholder(media_type, source.as_deref()),
183        ),
184    }
185}
186
187fn role_name(role: &Role) -> &'static str {
188    match role {
189        Role::User => "user",
190        Role::Assistant => "assistant",
191    }
192}
193
194/// Search the corpus. Returns (rendered matches, matched-block count shown,
195/// matching blocks beyond the cap).
196fn search(messages: &[Message], query: &str, max_matches: usize) -> (String, usize, usize) {
197    let needle = query.to_lowercase();
198    let mut rendered = Vec::new();
199    let mut shown = 0usize;
200    let mut beyond = 0usize;
201
202    for (idx, message) in messages.iter().enumerate() {
203        for block in &message.content {
204            let (kind, text) = block_text(block);
205            let windows = matching_windows(&text, &needle);
206            if windows.is_empty() {
207                continue;
208            }
209            if shown >= max_matches {
210                beyond += 1;
211                continue;
212            }
213            shown += 1;
214            let lines: Vec<&str> = text.lines().collect();
215            let mut body = String::new();
216            for (start, end) in &windows {
217                if !body.is_empty() {
218                    body.push_str("  ⋮\n");
219                }
220                for line in &lines[*start..*end] {
221                    body.push_str("  ");
222                    body.push_str(line);
223                    body.push('\n');
224                }
225            }
226            rendered.push(format!(
227                "[message {idx} · {} · {kind}]\n{body}",
228                role_name(&message.role)
229            ));
230        }
231    }
232    (rendered.join("\n"), shown, beyond)
233}
234
235/// Half-open line ranges around each matching line, overlapping ranges
236/// merged so a cluster of hits reads as one excerpt instead of repeating
237/// itself.
238fn matching_windows(text: &str, lowercase_needle: &str) -> Vec<(usize, usize)> {
239    let lines: Vec<&str> = text.lines().collect();
240    let mut windows: Vec<(usize, usize)> = Vec::new();
241    for (i, line) in lines.iter().enumerate() {
242        if !line.to_lowercase().contains(lowercase_needle) {
243            continue;
244        }
245        let start = i.saturating_sub(CONTEXT_LINES);
246        let end = (i + CONTEXT_LINES + 1).min(lines.len());
247        match windows.last_mut() {
248            Some((_, prev_end)) if start <= *prev_end => *prev_end = end,
249            _ => windows.push((start, end)),
250        }
251    }
252    windows
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::message::Message;
259    use crate::session::{Record, SessionMeta};
260    use crate::tool::ToolCtx;
261
262    fn write_transcript(records: &[Record]) -> PathBuf {
263        let path =
264            std::env::temp_dir().join(format!("mecha-recall-{}.jsonl", uuid::Uuid::new_v4()));
265        let body: String = records
266            .iter()
267            .map(|r| serde_json::to_string(r).unwrap() + "\n")
268            .collect();
269        std::fs::write(&path, body).unwrap();
270        path
271    }
272
273    fn meta() -> Record {
274        Record::Meta(SessionMeta {
275            id: "recall-test".into(),
276            created_at: chrono::Utc::now(),
277            provider: "scripted".into(),
278            model: "none".into(),
279            workspace: std::env::temp_dir(),
280            title: None,
281        })
282    }
283
284    fn ctx() -> ToolCtx {
285        ToolCtx::default().with_workspace(std::env::temp_dir())
286    }
287
288    async fn run(tool: &Recall, input: Value) -> ToolOutput {
289        tool.call(input, &ctx()).await.unwrap()
290    }
291
292    /// The reason the tool exists: content a compaction rewrite dropped is
293    /// still found, because the corpus is the union of everything ever
294    /// recorded, not the post-rewrite state a `load` would return.
295    #[tokio::test]
296    async fn finds_content_a_rewrite_dropped() {
297        let dropped = Message::assistant(vec![Block::text("the magic number is 74656")]);
298        let path = write_transcript(&[
299            meta(),
300            Record::Message(Message::user("compute the magic number")),
301            Record::Message(dropped),
302            Record::Rewrite {
303                messages: vec![Message::user("[summary: a number was computed]")],
304            },
305        ]);
306        let tool = Recall::new(path);
307
308        let out = run(&tool, json!({"query": "74656"})).await;
309        assert!(!out.is_error);
310        assert!(
311            out.content.contains("74656"),
312            "dropped content not found: {}",
313            out.content
314        );
315        assert!(
316            out.content.contains("assistant"),
317            "match not attributed: {}",
318            out.content
319        );
320
321        // The rewrite's own additions are searchable too.
322        let out = run(&tool, json!({"query": "summary:"})).await;
323        assert!(out.content.contains("[summary:"));
324    }
325
326    /// A message recorded once and repeated verbatim inside a rewrite is one
327    /// corpus entry, not two — otherwise every compaction would double every
328    /// surviving message's matches.
329    #[tokio::test]
330    async fn a_rewritten_duplicate_matches_once() {
331        let kept = Message::user("the anchor phrase");
332        let path = write_transcript(&[
333            meta(),
334            Record::Message(kept.clone()),
335            Record::Rewrite {
336                messages: vec![kept],
337            },
338        ]);
339        let out = run(&Recall::new(path), json!({"query": "anchor phrase"})).await;
340        assert!(
341            out.content.starts_with("1 matching block(s)"),
342            "{}",
343            out.content
344        );
345    }
346
347    #[tokio::test]
348    async fn matching_is_case_insensitive_and_labelled_by_block_kind() {
349        let path = write_transcript(&[
350            meta(),
351            Record::Message(Message::tool_results(vec![Block::ToolResult {
352                tool_use_id: "t1".into(),
353                content: "Quarterly Total: $12,345".into(),
354                is_error: false,
355            }])),
356        ]);
357        let out = run(&Recall::new(path), json!({"query": "quarterly total"})).await;
358        assert!(!out.is_error);
359        assert!(out.content.contains("tool_result"), "{}", out.content);
360        assert!(out.content.contains("$12,345"));
361    }
362
363    #[tokio::test]
364    async fn zero_matches_reports_the_corpus_size_not_an_error() {
365        let path = write_transcript(&[meta(), Record::Message(Message::user("hello"))]);
366        let out = run(&Recall::new(path), json!({"query": "absent"})).await;
367        assert!(!out.is_error);
368        assert!(out.content.contains("no matches"));
369        assert!(out.content.contains("1 recorded messages"));
370    }
371
372    #[tokio::test]
373    async fn a_missing_transcript_is_an_expected_failure() {
374        let tool = Recall::new(std::env::temp_dir().join("mecha-recall-nonexistent.jsonl"));
375        let out = run(&tool, json!({"query": "anything"})).await;
376        assert!(out.is_error);
377        assert!(out.content.contains("not be recording"));
378    }
379
380    #[tokio::test]
381    async fn an_empty_query_is_refused() {
382        let path = write_transcript(&[meta()]);
383        let out = run(&Recall::new(path), json!({"query": "  "})).await;
384        assert!(out.is_error);
385    }
386
387    #[tokio::test]
388    async fn the_match_cap_reports_what_it_hid() {
389        let records: Vec<Record> = std::iter::once(meta())
390            .chain((0..5).map(|i| Record::Message(Message::user(format!("needle row {i}")))))
391            .collect();
392        let path = write_transcript(&records);
393        let out = run(
394            &Recall::new(path),
395            json!({"query": "needle", "max_matches": 2}),
396        )
397        .await;
398        assert!(
399            out.content.contains("2 matching block(s)"),
400            "{}",
401            out.content
402        );
403        assert!(
404            out.content.contains("3 more matching block(s)"),
405            "{}",
406            out.content
407        );
408    }
409}