Skip to main content

mermaid_cli/providers/tool/
context.rs

1//! Tools over the model's own context: `context_archive` searches and pages
2//! the session log, `compact_context` asks for a checkpoint.
3//!
4//! Compaction removes messages from the working context, but nothing is
5//! deleted: every message the session committed is a `message` event in its
6//! `.jsonl` log, and a compaction only appends a boundary. So the checkpoint
7//! summary is not a single point of failure — when it left something out, the
8//! model searches the log and reads the original back, whole.
9//!
10//! `compact_context` lets the model decide when to checkpoint rather than
11//! waiting for the fill threshold, which stays as the safety net. The tool
12//! itself only records the ask (`ToolMetadata::CompactionRequest`); the
13//! reducer carries it onto the next request and the model-call path compacts
14//! before sending.
15
16use std::fmt::Write as _;
17use std::time::Instant;
18
19use async_trait::async_trait;
20
21use mermaid_domain::{SessionEvent, ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
22use mermaid_model::models::{ChatMessage, MessageRole};
23
24use super::super::ctx::ExecContext;
25use super::ToolExecutor;
26
27/// Ceiling on one `context_archive` result. Past it, results page.
28const ARCHIVE_OUTPUT_MAX_CHARS: usize = 32_000;
29/// Default and ceiling for `limit` on search and list.
30const ARCHIVE_DEFAULT_LIMIT: usize = 20;
31const ARCHIVE_MAX_LIMIT: usize = 200;
32/// Characters of context either side of a search hit.
33const SNIPPET_RADIUS: usize = 160;
34/// Characters of each message shown when listing.
35const LIST_PREVIEW_CHARS: usize = 200;
36
37pub struct ContextArchiveTool;
38
39pub struct CompactContextTool;
40
41/// One message from the log, numbered in the order it was committed.
42struct ArchivedMessage {
43    number: usize,
44    /// Id of the compaction that moved it out of the working context, if one
45    /// has since run.
46    compacted_by: Option<String>,
47    message: ChatMessage,
48}
49
50impl ArchivedMessage {
51    fn heading(&self) -> String {
52        let role = match self.message.role {
53            MessageRole::User => "user",
54            MessageRole::Assistant => "assistant",
55            MessageRole::System => "system",
56            MessageRole::Tool => "tool",
57        };
58        let mut heading = format!("#{} {role}", self.number);
59        if let Some(name) = &self.message.tool_name {
60            let _ = write!(heading, " ({name})");
61        }
62        if let Some(id) = &self.compacted_by {
63            let _ = write!(heading, " [compacted by {id}]");
64        }
65        heading
66    }
67
68    /// Everything searchable about the message: its text plus the calls it
69    /// made, since a file write lives in the arguments, not the content.
70    fn text(&self) -> String {
71        let mut text = self.message.content.clone();
72        for call in self.message.tool_calls.iter().flatten() {
73            let _ = write!(
74                text,
75                "\n[tool_call {} {}]",
76                call.function.name, call.function.arguments
77            );
78        }
79        text
80    }
81}
82
83/// Number the committed messages of a session log in commit order and mark
84/// which a later compaction moved out of the working context.
85///
86/// A log opens with a `reset` carrying the transcript as it stood when the
87/// log was created (and a rewind writes another), so a reset contributes the
88/// messages it holds that the archive has not seen yet. Messages a reset or a
89/// compaction dropped stay: they happened, and that is what the archive is
90/// for.
91fn archived_messages(events: Vec<SessionEvent>) -> Vec<ArchivedMessage> {
92    use mermaid_domain::CompactionBoundary;
93    let mut out: Vec<ArchivedMessage> = Vec::new();
94    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
95    let push = |out: &mut Vec<ArchivedMessage>,
96                seen: &mut std::collections::HashSet<String>,
97                message: ChatMessage| {
98        seen.insert(CompactionBoundary::fingerprint_of(&message));
99        out.push(ArchivedMessage {
100            number: out.len() + 1,
101            compacted_by: None,
102            message,
103        });
104    };
105    for event in events {
106        match event {
107            SessionEvent::Message { message } | SessionEvent::InsertedBeforeLast { message } => {
108                push(&mut out, &mut seen, message);
109            },
110            SessionEvent::Reset { messages, .. } => {
111                for message in messages {
112                    if !seen.contains(&CompactionBoundary::fingerprint_of(&message)) {
113                        push(&mut out, &mut seen, message);
114                    }
115                }
116            },
117            SessionEvent::Compaction {
118                record,
119                replacement,
120                ..
121            } => {
122                // Whatever the replacement kept verbatim is still live; every
123                // earlier message not already claimed by a compaction went.
124                let kept: std::collections::HashSet<String> = replacement
125                    .iter()
126                    .map(CompactionBoundary::fingerprint_of)
127                    .collect();
128                for entry in &mut out {
129                    if entry.compacted_by.is_none()
130                        && !kept.contains(&CompactionBoundary::fingerprint_of(&entry.message))
131                    {
132                        entry.compacted_by = Some(record.id.clone());
133                    }
134                }
135            },
136            SessionEvent::Started { .. }
137            | SessionEvent::Action { .. }
138            | SessionEvent::Image { .. }
139            | SessionEvent::State(_)
140            | SessionEvent::Input { .. }
141            | SessionEvent::Tasks { .. } => {},
142        }
143    }
144    out
145}
146
147fn usize_arg(args: &serde_json::Value, key: &str) -> Option<usize> {
148    args.get(key)
149        .and_then(serde_json::Value::as_u64)
150        .and_then(|n| usize::try_from(n).ok())
151}
152
153fn str_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
154    args.get(key)
155        .and_then(serde_json::Value::as_str)
156        .map(str::trim)
157        .filter(|s| !s.is_empty())
158}
159
160/// `text[start..end]` by characters, with ellipses where it was cut.
161fn char_window(text: &str, start: usize, end: usize) -> String {
162    let total = text.chars().count();
163    let end = end.min(total);
164    let start = start.min(end);
165    let mut out = String::new();
166    if start > 0 {
167        out.push('…');
168    }
169    out.extend(text.chars().skip(start).take(end - start));
170    if end < total {
171        out.push('…');
172    }
173    out
174}
175
176fn one_line(text: &str) -> String {
177    text.split_whitespace().collect::<Vec<_>>().join(" ")
178}
179
180/// Case-insensitive search. Returns the rendered hits and how many matched.
181fn search(messages: &[ArchivedMessage], query: &str, from: usize, limit: usize) -> (String, usize) {
182    let needle = query.to_lowercase();
183    let mut out = String::new();
184    let mut matched = 0usize;
185    let mut shown = 0usize;
186    let mut next_from = None;
187    for entry in messages.iter().filter(|m| m.number >= from) {
188        let text = entry.text();
189        let lower = text.to_lowercase();
190        let Some(byte_at) = lower.find(&needle) else {
191            continue;
192        };
193        matched += 1;
194        if shown >= limit || out.len() >= ARCHIVE_OUTPUT_MAX_CHARS {
195            next_from.get_or_insert(entry.number);
196            continue;
197        }
198        // Lowercasing can change byte lengths, so locate the hit by chars.
199        let char_at = lower[..byte_at].chars().count();
200        let hits = lower.matches(&needle).count();
201        let snippet = one_line(&char_window(
202            &text,
203            char_at.saturating_sub(SNIPPET_RADIUS),
204            char_at + needle.chars().count() + SNIPPET_RADIUS,
205        ));
206        let _ = writeln!(
207            out,
208            "{} — {hits} hit{}\n  {snippet}",
209            entry.heading(),
210            if hits == 1 { "" } else { "s" }
211        );
212        shown += 1;
213    }
214    if matched == 0 {
215        return (format!("No messages match \"{query}\"."), 0);
216    }
217    let mut header = format!("{matched} message(s) match \"{query}\"");
218    if let Some(next) = next_from {
219        let _ = write!(header, "; showing {shown}, pass from={next} for more");
220    }
221    header.push_str(". Read one whole with `message`.\n\n");
222    (header + &out, matched)
223}
224
225fn list(messages: &[ArchivedMessage], from: usize, limit: usize) -> (String, usize) {
226    let mut out = format!(
227        "{} message(s) in this session's log. Read one whole with `message`.\n\n",
228        messages.len()
229    );
230    let mut shown = 0usize;
231    for entry in messages.iter().filter(|m| m.number >= from) {
232        if shown >= limit || out.len() >= ARCHIVE_OUTPUT_MAX_CHARS {
233            let _ = write!(
234                out,
235                "\nMore from #{}: pass from={}.",
236                entry.number, entry.number
237            );
238            break;
239        }
240        let preview = one_line(&char_window(&entry.text(), 0, LIST_PREVIEW_CHARS));
241        let _ = writeln!(out, "{}: {preview}", entry.heading());
242        shown += 1;
243    }
244    (out, shown)
245}
246
247fn read(messages: &[ArchivedMessage], number: usize, char_offset: usize) -> Result<String, String> {
248    let entry = messages
249        .iter()
250        .find(|m| m.number == number)
251        .ok_or_else(|| {
252            format!(
253                "No message #{number}; this session's log holds #1 to #{}.",
254                messages.len()
255            )
256        })?;
257    let text = entry.text();
258    let total = text.chars().count();
259    let end = char_offset
260        .saturating_add(ARCHIVE_OUTPUT_MAX_CHARS)
261        .min(total);
262    let body: String = text
263        .chars()
264        .skip(char_offset)
265        .take(end.saturating_sub(char_offset))
266        .collect();
267    let mut out = format!("{} — {total} chars", entry.heading());
268    if char_offset > 0 || end < total {
269        let _ = write!(out, ", showing {char_offset}..{end}");
270        if end < total {
271            let _ = write!(out, "; pass char_offset={end} for the rest");
272        }
273    }
274    out.push_str("\n\n");
275    out.push_str(&body);
276    Ok(out)
277}
278
279#[async_trait]
280impl ToolExecutor for ContextArchiveTool {
281    fn name(&self) -> &'static str {
282        "context_archive"
283    }
284
285    fn schema(&self) -> ToolDefinition {
286        ToolDefinition {
287            name: "context_archive".to_string(),
288            description: "Search or read this session's full message history, including \
289                everything compaction removed from your context. Messages are numbered in the \
290                order they happened and kept whole, tool output included. Pass `query` to find \
291                messages containing a string (case-insensitive), `message` to read one in full, \
292                or neither to list them."
293                .to_string(),
294            input_schema: serde_json::json!({
295                "type": "object",
296                "properties": {
297                    "query": {
298                        "type": "string",
299                        "description": "Text to search for, case-insensitive."
300                    },
301                    "message": {
302                        "type": "integer",
303                        "minimum": 1,
304                        "description": "Number of one message to read in full."
305                    },
306                    "char_offset": {
307                        "type": "integer",
308                        "minimum": 0,
309                        "description": "With `message`: where to resume a long message."
310                    },
311                    "from": {
312                        "type": "integer",
313                        "minimum": 1,
314                        "description": "Search or list from this message number on."
315                    },
316                    "limit": {
317                        "type": "integer",
318                        "minimum": 1,
319                        "maximum": ARCHIVE_MAX_LIMIT,
320                        "description": "Most messages to return when searching or listing."
321                    }
322                }
323            }),
324        }
325    }
326
327    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
328        let started = Instant::now();
329        let secs = || started.elapsed().as_secs_f64();
330        let Some(session_id) = ctx.session_id.clone() else {
331            return ToolOutcome::error(
332                "This run keeps no session log, so there is no archive to search.",
333                Some(secs()),
334            );
335        };
336        let workdir = ctx.workdir.clone();
337        let loaded = tokio::task::spawn_blocking(move || {
338            crate::session::ConversationManager::new(&workdir)
339                .and_then(|manager| manager.read_session_events(&session_id))
340        })
341        .await;
342        let events = match loaded {
343            Ok(Ok(Some(events))) => events,
344            Ok(Ok(None)) => Vec::new(),
345            Ok(Err(error)) => {
346                return ToolOutcome::error(
347                    format!("Could not read the session log: {error}"),
348                    Some(secs()),
349                );
350            },
351            Err(error) => {
352                return ToolOutcome::error(
353                    format!("Reading the session log failed: {error}"),
354                    Some(secs()),
355                );
356            },
357        };
358        let messages = archived_messages(events);
359        if messages.is_empty() {
360            return ToolOutcome::success("The session log holds no messages yet.", "empty", secs());
361        }
362
363        let query = str_arg(&args, "query");
364        let from = usize_arg(&args, "from").unwrap_or(1).max(1);
365        let limit = usize_arg(&args, "limit")
366            .unwrap_or(ARCHIVE_DEFAULT_LIMIT)
367            .clamp(1, ARCHIVE_MAX_LIMIT);
368        let (output, summary, result_count) = if let Some(number) = usize_arg(&args, "message") {
369            match read(
370                &messages,
371                number,
372                usize_arg(&args, "char_offset").unwrap_or(0),
373            ) {
374                Ok(output) => (output, format!("message {number}"), 1),
375                Err(error) => return ToolOutcome::error(error, Some(secs())),
376            }
377        } else if let Some(query) = query {
378            let (output, matched) = search(&messages, query, from, limit);
379            (output, format!("{matched} matched"), matched)
380        } else {
381            let (output, shown) = list(&messages, from, limit);
382            (output, format!("{shown} listed"), shown)
383        };
384        // The log holds what the model saw, but this result is committed and
385        // persisted again; scrub anything credential-shaped on the way out.
386        let output = mermaid_model::utils::redact_secrets(&output);
387        ToolOutcome::success(output, summary, secs()).with_metadata(ToolRunMetadata {
388            detail: ToolMetadata::ContextArchive {
389                query: query.map(mermaid_model::utils::redact_secrets),
390                result_count,
391            },
392            result_count: Some(result_count),
393            ..ToolRunMetadata::default()
394        })
395    }
396}
397
398#[async_trait]
399impl ToolExecutor for CompactContextTool {
400    fn name(&self) -> &'static str {
401        "compact_context"
402    }
403
404    fn schema(&self) -> ToolDefinition {
405        ToolDefinition {
406            name: "compact_context".to_string(),
407            description: "Checkpoint your context before your next step: the older conversation \
408                is summarized into a handoff and removed from your context, and the most recent \
409                messages stay verbatim. Nothing is lost; `context_archive` can read any removed \
410                message back. Mermaid also compacts on its own when the context window is nearly \
411                full."
412                .to_string(),
413            input_schema: serde_json::json!({
414                "type": "object",
415                "properties": {
416                    "focus": {
417                        "type": "string",
418                        "description": "What the handoff should emphasize."
419                    }
420                }
421            }),
422        }
423    }
424
425    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
426        let _ = ctx;
427        let focus = str_arg(&args, "focus").map(str::to_string);
428        ToolOutcome::success(
429            "Checkpoint requested; it runs before your next step.",
430            "requested",
431            0.0,
432        )
433        .with_metadata(ToolRunMetadata {
434            detail: ToolMetadata::CompactionRequest { focus },
435            ..ToolRunMetadata::default()
436        })
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::providers::ctx::test_exec_context;
444    use mermaid_domain::{CompactionEvent, CompactionTrigger, ToolCallId, TurnId};
445
446    fn record(id: &str) -> CompactionEvent {
447        CompactionEvent {
448            id: id.to_string(),
449            trigger: CompactionTrigger::Manual,
450            created_at: chrono::Local::now(),
451            before_tokens: 100,
452            after_tokens: 10,
453            archived_message_count: 2,
454            preserved_message_count: 1,
455            preserved_turn_count: 1,
456            summary_tokens: 5,
457            duration_secs: 0.1,
458            focus: None,
459            archive_path: None,
460        }
461    }
462
463    fn log() -> Vec<SessionEvent> {
464        let kept = ChatMessage::user("third: carry on");
465        let mut call = ChatMessage::assistant("writing it");
466        call.tool_calls = Some(vec![mermaid_model::models::tool_call::ToolCall {
467            id: Some("c1".to_string()),
468            function: mermaid_model::models::tool_call::FunctionCall {
469                name: "write_file".to_string(),
470                arguments: serde_json::json!({"path": "src/lexer.rs"}),
471            },
472        }]);
473        vec![
474            SessionEvent::Message {
475                message: ChatMessage::user("first: build the Lexer"),
476            },
477            SessionEvent::Message { message: call },
478            SessionEvent::Message {
479                message: ChatMessage::tool("c1", "write_file", "x".repeat(50_000)),
480            },
481            SessionEvent::Message {
482                message: kept.clone(),
483            },
484            SessionEvent::Compaction {
485                at: chrono::Local::now(),
486                record: record("compact_1"),
487                replacement: vec![ChatMessage::user("checkpoint"), kept],
488            },
489            SessionEvent::Message {
490                message: ChatMessage::assistant("after the checkpoint"),
491            },
492        ]
493    }
494
495    #[test]
496    fn messages_are_numbered_in_commit_order_and_marked_when_compacted() {
497        let messages = archived_messages(log());
498        assert_eq!(messages.len(), 5);
499        assert_eq!(messages[0].number, 1);
500        assert_eq!(messages[0].compacted_by.as_deref(), Some("compact_1"));
501        assert_eq!(messages[2].compacted_by.as_deref(), Some("compact_1"));
502        assert_eq!(messages[3].compacted_by, None, "the preserved tail is live");
503        assert_eq!(messages[4].compacted_by, None);
504    }
505
506    #[test]
507    fn a_reset_contributes_only_messages_not_seen_yet() {
508        let first = ChatMessage::user("seeded by the backfill");
509        let second = ChatMessage::assistant("also seeded");
510        let events = vec![
511            SessionEvent::Reset {
512                at: chrono::Local::now(),
513                messages: vec![first.clone(), second],
514            },
515            SessionEvent::Message {
516                message: ChatMessage::user("appended later"),
517            },
518            // A rewind back to the first message: nothing new, nothing lost.
519            SessionEvent::Reset {
520                at: chrono::Local::now(),
521                messages: vec![first],
522            },
523        ];
524        let messages = archived_messages(events);
525        let texts: Vec<&str> = messages
526            .iter()
527            .map(|m| m.message.content.as_str())
528            .collect();
529        assert_eq!(
530            texts,
531            ["seeded by the backfill", "also seeded", "appended later"]
532        );
533    }
534
535    #[test]
536    fn search_is_case_insensitive_and_reaches_tool_call_arguments() {
537        let messages = archived_messages(log());
538        let (out, matched) = search(&messages, "lexer", 1, 20);
539        assert_eq!(matched, 2, "{out}");
540        assert!(out.contains("#1 user [compacted by compact_1]"), "{out}");
541        assert!(out.contains("#2 assistant"), "{out}");
542        let (none, zero) = search(&messages, "parser", 1, 20);
543        assert_eq!(zero, 0);
544        assert!(none.contains("No messages match"));
545    }
546
547    #[test]
548    fn search_pages_past_the_limit() {
549        let messages = archived_messages(log());
550        let (out, matched) = search(&messages, "e", 1, 1);
551        assert!(matched > 1);
552        assert!(out.contains("pass from="), "{out}");
553    }
554
555    #[test]
556    fn read_returns_a_long_message_whole_across_pages() {
557        let messages = archived_messages(log());
558        let first = read(&messages, 3, 0).expect("message 3");
559        assert!(first.contains("50000 chars"), "{}", &first[..120]);
560        assert!(first.contains("char_offset=32000"));
561        let rest = read(&messages, 3, 32_000).expect("rest");
562        let body = rest.split_once("\n\n").expect("header").1;
563        assert_eq!(body.len(), 18_000);
564        assert!(read(&messages, 99, 0).is_err());
565    }
566
567    #[test]
568    fn list_previews_every_message() {
569        let messages = archived_messages(log());
570        let (out, shown) = list(&messages, 1, 20);
571        assert_eq!(shown, 5);
572        assert!(out.contains("#5 assistant: after the checkpoint"), "{out}");
573    }
574
575    #[tokio::test]
576    async fn archive_reads_the_session_log_from_disk() {
577        let dir =
578            std::env::temp_dir().join(format!("mermaid-context-archive-{}", std::process::id()));
579        let _ = std::fs::remove_dir_all(&dir);
580        let manager = crate::session::ConversationManager::new(&dir).expect("manager");
581        let mut history = mermaid_domain::ConversationHistory::new(
582            dir.display().to_string(),
583            "ollama/test".to_string(),
584            chrono::Local::now(),
585        );
586        history.add_messages(
587            &[ChatMessage::user("remember the Zebra flag")],
588            chrono::Local::now(),
589        );
590        let events = vec![SessionEvent::Message {
591            message: history.messages()[0].clone(),
592        }];
593        manager
594            .append_session_events(&history, &events)
595            .expect("append");
596
597        let (mut ctx, _progress) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
598        ctx.session_id = Some(history.id.clone());
599        let outcome = ContextArchiveTool
600            .execute(serde_json::json!({"query": "zebra"}), ctx)
601            .await;
602        assert!(outcome.is_success(), "{}", outcome.output());
603        assert!(
604            outcome.output().contains("Zebra flag"),
605            "{}",
606            outcome.output()
607        );
608        let _ = std::fs::remove_dir_all(&dir);
609    }
610
611    #[tokio::test]
612    async fn archive_without_a_session_explains_itself() {
613        let (ctx, _progress) = test_exec_context(TurnId(1), ToolCallId(1), "/tmp".into());
614        let outcome = ContextArchiveTool
615            .execute(serde_json::json!({"query": "x"}), ctx)
616            .await;
617        assert!(!outcome.is_success());
618        assert!(outcome.output().contains("no session log"));
619    }
620
621    #[tokio::test]
622    async fn compact_context_records_the_request_with_its_focus() {
623        let (ctx, _progress) = test_exec_context(TurnId(1), ToolCallId(1), "/tmp".into());
624        let outcome = CompactContextTool
625            .execute(serde_json::json!({"focus": "the parser"}), ctx)
626            .await;
627        assert!(outcome.is_success());
628        assert_eq!(
629            outcome.metadata.detail,
630            ToolMetadata::CompactionRequest {
631                focus: Some("the parser".to_string())
632            }
633        );
634    }
635}