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    }
173}
174
175fn role_name(role: &Role) -> &'static str {
176    match role {
177        Role::User => "user",
178        Role::Assistant => "assistant",
179    }
180}
181
182/// Search the corpus. Returns (rendered matches, matched-block count shown,
183/// matching blocks beyond the cap).
184fn search(messages: &[Message], query: &str, max_matches: usize) -> (String, usize, usize) {
185    let needle = query.to_lowercase();
186    let mut rendered = Vec::new();
187    let mut shown = 0usize;
188    let mut beyond = 0usize;
189
190    for (idx, message) in messages.iter().enumerate() {
191        for block in &message.content {
192            let (kind, text) = block_text(block);
193            let windows = matching_windows(&text, &needle);
194            if windows.is_empty() {
195                continue;
196            }
197            if shown >= max_matches {
198                beyond += 1;
199                continue;
200            }
201            shown += 1;
202            let lines: Vec<&str> = text.lines().collect();
203            let mut body = String::new();
204            for (start, end) in &windows {
205                if !body.is_empty() {
206                    body.push_str("  ⋮\n");
207                }
208                for line in &lines[*start..*end] {
209                    body.push_str("  ");
210                    body.push_str(line);
211                    body.push('\n');
212                }
213            }
214            rendered.push(format!(
215                "[message {idx} · {} · {kind}]\n{body}",
216                role_name(&message.role)
217            ));
218        }
219    }
220    (rendered.join("\n"), shown, beyond)
221}
222
223/// Half-open line ranges around each matching line, overlapping ranges
224/// merged so a cluster of hits reads as one excerpt instead of repeating
225/// itself.
226fn matching_windows(text: &str, lowercase_needle: &str) -> Vec<(usize, usize)> {
227    let lines: Vec<&str> = text.lines().collect();
228    let mut windows: Vec<(usize, usize)> = Vec::new();
229    for (i, line) in lines.iter().enumerate() {
230        if !line.to_lowercase().contains(lowercase_needle) {
231            continue;
232        }
233        let start = i.saturating_sub(CONTEXT_LINES);
234        let end = (i + CONTEXT_LINES + 1).min(lines.len());
235        match windows.last_mut() {
236            Some((_, prev_end)) if start <= *prev_end => *prev_end = end,
237            _ => windows.push((start, end)),
238        }
239    }
240    windows
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::message::Message;
247    use crate::session::{Record, SessionMeta};
248    use crate::tool::ToolCtx;
249
250    fn write_transcript(records: &[Record]) -> PathBuf {
251        let path =
252            std::env::temp_dir().join(format!("mecha-recall-{}.jsonl", uuid::Uuid::new_v4()));
253        let body: String = records
254            .iter()
255            .map(|r| serde_json::to_string(r).unwrap() + "\n")
256            .collect();
257        std::fs::write(&path, body).unwrap();
258        path
259    }
260
261    fn meta() -> Record {
262        Record::Meta(SessionMeta {
263            id: "recall-test".into(),
264            created_at: chrono::Utc::now(),
265            provider: "scripted".into(),
266            model: "none".into(),
267            workspace: std::env::temp_dir(),
268            title: None,
269        })
270    }
271
272    fn ctx() -> ToolCtx {
273        ToolCtx::default().with_workspace(std::env::temp_dir())
274    }
275
276    async fn run(tool: &Recall, input: Value) -> ToolOutput {
277        tool.call(input, &ctx()).await.unwrap()
278    }
279
280    /// The reason the tool exists: content a compaction rewrite dropped is
281    /// still found, because the corpus is the union of everything ever
282    /// recorded, not the post-rewrite state a `load` would return.
283    #[tokio::test]
284    async fn finds_content_a_rewrite_dropped() {
285        let dropped = Message::assistant(vec![Block::text("the magic number is 74656")]);
286        let path = write_transcript(&[
287            meta(),
288            Record::Message(Message::user("compute the magic number")),
289            Record::Message(dropped),
290            Record::Rewrite {
291                messages: vec![Message::user("[summary: a number was computed]")],
292            },
293        ]);
294        let tool = Recall::new(path);
295
296        let out = run(&tool, json!({"query": "74656"})).await;
297        assert!(!out.is_error);
298        assert!(
299            out.content.contains("74656"),
300            "dropped content not found: {}",
301            out.content
302        );
303        assert!(
304            out.content.contains("assistant"),
305            "match not attributed: {}",
306            out.content
307        );
308
309        // The rewrite's own additions are searchable too.
310        let out = run(&tool, json!({"query": "summary:"})).await;
311        assert!(out.content.contains("[summary:"));
312    }
313
314    /// A message recorded once and repeated verbatim inside a rewrite is one
315    /// corpus entry, not two — otherwise every compaction would double every
316    /// surviving message's matches.
317    #[tokio::test]
318    async fn a_rewritten_duplicate_matches_once() {
319        let kept = Message::user("the anchor phrase");
320        let path = write_transcript(&[
321            meta(),
322            Record::Message(kept.clone()),
323            Record::Rewrite {
324                messages: vec![kept],
325            },
326        ]);
327        let out = run(&Recall::new(path), json!({"query": "anchor phrase"})).await;
328        assert!(
329            out.content.starts_with("1 matching block(s)"),
330            "{}",
331            out.content
332        );
333    }
334
335    #[tokio::test]
336    async fn matching_is_case_insensitive_and_labelled_by_block_kind() {
337        let path = write_transcript(&[
338            meta(),
339            Record::Message(Message::tool_results(vec![Block::ToolResult {
340                tool_use_id: "t1".into(),
341                content: "Quarterly Total: $12,345".into(),
342                is_error: false,
343            }])),
344        ]);
345        let out = run(&Recall::new(path), json!({"query": "quarterly total"})).await;
346        assert!(!out.is_error);
347        assert!(out.content.contains("tool_result"), "{}", out.content);
348        assert!(out.content.contains("$12,345"));
349    }
350
351    #[tokio::test]
352    async fn zero_matches_reports_the_corpus_size_not_an_error() {
353        let path = write_transcript(&[meta(), Record::Message(Message::user("hello"))]);
354        let out = run(&Recall::new(path), json!({"query": "absent"})).await;
355        assert!(!out.is_error);
356        assert!(out.content.contains("no matches"));
357        assert!(out.content.contains("1 recorded messages"));
358    }
359
360    #[tokio::test]
361    async fn a_missing_transcript_is_an_expected_failure() {
362        let tool = Recall::new(std::env::temp_dir().join("mecha-recall-nonexistent.jsonl"));
363        let out = run(&tool, json!({"query": "anything"})).await;
364        assert!(out.is_error);
365        assert!(out.content.contains("not be recording"));
366    }
367
368    #[tokio::test]
369    async fn an_empty_query_is_refused() {
370        let path = write_transcript(&[meta()]);
371        let out = run(&Recall::new(path), json!({"query": "  "})).await;
372        assert!(out.is_error);
373    }
374
375    #[tokio::test]
376    async fn the_match_cap_reports_what_it_hid() {
377        let records: Vec<Record> = std::iter::once(meta())
378            .chain((0..5).map(|i| Record::Message(Message::user(format!("needle row {i}")))))
379            .collect();
380        let path = write_transcript(&records);
381        let out = run(
382            &Recall::new(path),
383            json!({"query": "needle", "max_matches": 2}),
384        )
385        .await;
386        assert!(
387            out.content.contains("2 matching block(s)"),
388            "{}",
389            out.content
390        );
391        assert!(
392            out.content.contains("3 more matching block(s)"),
393            "{}",
394            out.content
395        );
396    }
397}