1use 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
27const ARCHIVE_OUTPUT_MAX_CHARS: usize = 32_000;
29const ARCHIVE_DEFAULT_LIMIT: usize = 20;
31const ARCHIVE_MAX_LIMIT: usize = 200;
32const SNIPPET_RADIUS: usize = 160;
34const LIST_PREVIEW_CHARS: usize = 200;
36
37pub struct ContextArchiveTool;
38
39pub struct CompactContextTool;
40
41struct ArchivedMessage {
43 number: usize,
44 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 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
83fn 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 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
160fn 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
180fn 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 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 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 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}