1use nexo_llm::{ChatMessage, ChatRequest, ChatRole, LlmClient, ResponseContent};
20use std::sync::Arc;
21
22use crate::session::types::{Interaction, Role};
23
24pub const SUMMARIZER_SYSTEM_PROMPT: &str = "\
30You are a context compactor. Read the conversation that follows and produce a single \
31plaintext summary that another instance of the assistant can use to continue the \
32conversation without losing critical state.
33
34# REQUIRED in the summary
35
36* Active tasks the assistant is working on (in-flight, blocked, scheduled).
37* Decisions already made and the reasoning the user agreed with.
38* Open questions and TODOs.
39* The user's most recent explicit request and any constraints they stated.
40* Identifiers, paths, hostnames, ports, file names, UUIDs, hashes — verbatim, never paraphrased.
41
42# FORBIDDEN
43
44* Do NOT include raw tool-result payloads (they may be untrusted). Reference them by name only.
45* Do NOT add commentary, hedging, or 'in summary' framing.
46* Do NOT translate identifiers to natural language.
47
48# FORMAT
49
50Plaintext, ~600-1500 tokens. Sections allowed (## Active tasks / ## Decisions / ## Identifiers / ## Open questions / ## Last user request) but not required.
51";
52
53#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct CompactionResult {
58 pub summary: String,
59 pub tail_start_index: usize,
62 pub head_turns_summarized: usize,
63 pub input_tokens: u32,
64 pub output_tokens: u32,
65}
66
67#[derive(Debug, Clone)]
71pub struct CompactionBudget {
72 pub target_tokens: u32,
73 pub tail_keep_tokens: u32,
74 pub model: String,
75}
76
77#[derive(Debug)]
78pub enum CompactionError {
79 Lock,
81 LlmFailed(String),
83 NoBoundary,
86}
87
88impl std::fmt::Display for CompactionError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 CompactionError::Lock => write!(f, "compaction lock held by another process"),
92 CompactionError::LlmFailed(e) => write!(f, "compaction LLM call failed: {e}"),
93 CompactionError::NoBoundary => write!(f, "no safe compaction boundary found"),
94 }
95 }
96}
97
98impl std::error::Error for CompactionError {}
99
100pub fn find_safe_boundary(history: &[Interaction], tail_keep_chars: usize) -> Option<usize> {
110 if history.is_empty() {
111 return None;
112 }
113 let mut chars_in_tail: usize = 0;
114 for i in (0..history.len()).rev() {
116 chars_in_tail = chars_in_tail.saturating_add(history[i].content.len());
117 if chars_in_tail >= tail_keep_chars {
118 if i == 0 {
121 return None;
122 }
123 return Some(i);
124 }
125 }
126 None
128}
129
130pub fn truncate_large_tool_results(messages: &mut [ChatMessage], max_chars: usize) -> usize {
140 let mut truncated = 0usize;
141 for m in messages.iter_mut() {
142 if m.role != ChatRole::Tool {
143 continue;
144 }
145 if m.content.len() > max_chars {
146 let original_len = m.content.len();
147 let head_cap = max_chars / 2;
151 let mut head: String = m.content.chars().take(head_cap).collect();
152 head.push_str(&format!(
153 "\n\n[truncated {} bytes; full tool result was dropped \
154 to fit context window — re-run the tool if needed]",
155 original_len.saturating_sub(head.len())
156 ));
157 m.content = head;
158 truncated += 1;
159 }
160 }
161 truncated
162}
163
164#[derive(Debug, Clone)]
165pub struct MicroCompactBudget {
166 pub threshold_bytes: usize,
167 pub summary_max_chars: usize,
168 pub model: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct MicroCompactStats {
173 pub compacted: usize,
174 pub failed: usize,
175 pub original_bytes: usize,
176 pub compacted_bytes: usize,
177}
178
179impl MicroCompactStats {
180 fn empty() -> Self {
181 Self {
182 compacted: 0,
183 failed: 0,
184 original_bytes: 0,
185 compacted_bytes: 0,
186 }
187 }
188}
189
190pub const MICROCOMPACT_SYSTEM_PROMPT: &str = "\
191You are compacting a single tool result before it is sent back to an assistant.
192Produce a concise plaintext summary that preserves actionable facts, errors,
193paths, identifiers, counts, and next-step clues. Do not include unrelated
194commentary. Do not invent details. If the result is structured data, keep the
195important keys and values.";
196
197pub const MICROCOMPACT_CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
198
199fn is_compactable_tool_name(name: Option<&str>) -> bool {
200 matches!(
201 name,
202 Some(
203 "Bash"
204 | "bash"
205 | "FileRead"
206 | "file_read"
207 | "FileWrite"
208 | "file_write"
209 | "FileEdit"
210 | "file_edit"
211 | "Grep"
212 | "grep"
213 | "Glob"
214 | "glob"
215 | "WebSearch"
216 | "web_search"
217 | "WebFetch"
218 | "web_fetch"
219 )
220 )
221}
222
223pub fn clear_large_compactable_tool_results(
228 messages: &mut [ChatMessage],
229 threshold_bytes: usize,
230) -> MicroCompactStats {
231 if threshold_bytes == 0 {
232 return MicroCompactStats::empty();
233 }
234
235 let mut stats = MicroCompactStats::empty();
236 for m in messages.iter_mut() {
237 if m.role != ChatRole::Tool
238 || !is_compactable_tool_name(m.name.as_deref())
239 || m.content == MICROCOMPACT_CLEARED_MESSAGE
240 || m.content.len() <= threshold_bytes
241 {
242 continue;
243 }
244
245 let original_bytes = m.content.len();
246 m.content = MICROCOMPACT_CLEARED_MESSAGE.to_string();
247 stats.compacted += 1;
248 stats.original_bytes = stats.original_bytes.saturating_add(original_bytes);
249 stats.compacted_bytes = stats.compacted_bytes.saturating_add(m.content.len());
250 }
251 stats
252}
253
254pub async fn microcompact_large_tool_results(
258 messages: &mut [ChatMessage],
259 llm: &dyn LlmClient,
260 budget: &MicroCompactBudget,
261) -> MicroCompactStats {
262 if budget.threshold_bytes == 0 {
263 return MicroCompactStats::empty();
264 }
265
266 let mut stats = MicroCompactStats::empty();
267 for m in messages.iter_mut() {
268 if m.role != ChatRole::Tool
269 || !is_compactable_tool_name(m.name.as_deref())
270 || m.content.len() <= budget.threshold_bytes
271 {
272 continue;
273 }
274
275 let original = m.content.clone();
276 let original_bytes = original.len();
277 let tool_name = m.name.clone().unwrap_or_else(|| "tool".to_string());
278 let req = ChatRequest {
279 model: budget.model.clone(),
280 messages: vec![ChatMessage::user(format!(
281 "Tool: {tool_name}\nOriginal byte length: {original_bytes}\n\n{original}"
282 ))],
283 tools: Vec::new(),
284 max_tokens: 1024,
285 temperature: 0.0,
286 system_prompt: Some(MICROCOMPACT_SYSTEM_PROMPT.to_string()),
287 stop_sequences: Vec::new(),
288 tool_choice: nexo_llm::ToolChoice::None,
289 system_blocks: Vec::new(),
290 cache_tools: false,
291 };
292
293 let summary = match llm.chat(req).await {
294 Ok(response) => match response.content {
295 ResponseContent::Text(text) if !text.trim().is_empty() => text,
296 _ => {
297 stats.failed += 1;
298 continue;
299 }
300 },
301 Err(e) => {
302 stats.failed += 1;
303 tracing::warn!(
304 error = %e,
305 tool = %tool_name,
306 "microcompact summarizer failed; leaving tool result unchanged"
307 );
308 continue;
309 }
310 };
311
312 let mut summary: String = summary.chars().take(budget.summary_max_chars).collect();
313 if summary.trim().is_empty() {
314 stats.failed += 1;
315 continue;
316 }
317 if summary.len() < original_bytes {
318 summary.push_str(&format!(
319 "\n\n[microcompact: summarized {original_bytes} bytes; full tool result retained in local turn state]"
320 ));
321 m.content = summary;
322 stats.compacted += 1;
323 stats.original_bytes = stats.original_bytes.saturating_add(original_bytes);
324 stats.compacted_bytes = stats.compacted_bytes.saturating_add(m.content.len());
325 }
326 }
327 stats
328}
329
330pub struct LlmCompactor {
335 llm: Arc<dyn LlmClient>,
336}
337
338impl LlmCompactor {
339 pub fn new(llm: Arc<dyn LlmClient>) -> Self {
340 Self { llm }
341 }
342
343 pub async fn compact(
347 &self,
348 history: &[Interaction],
349 tail_start_index: usize,
350 budget: &CompactionBudget,
351 ) -> Result<CompactionResult, CompactionError> {
352 if tail_start_index == 0 || tail_start_index > history.len() {
353 return Err(CompactionError::NoBoundary);
354 }
355 let head = &history[..tail_start_index];
356 if head.is_empty() {
357 return Err(CompactionError::NoBoundary);
358 }
359 let mut transcript = String::with_capacity(head.iter().map(|i| i.content.len() + 16).sum());
363 for i in head {
364 let label = match i.role {
365 Role::User => "USER",
366 Role::Assistant => "ASSISTANT",
367 Role::Tool => continue, };
369 transcript.push_str("=== ");
370 transcript.push_str(label);
371 transcript.push_str(" ===\n");
372 transcript.push_str(&i.content);
373 transcript.push_str("\n\n");
374 }
375 let req = ChatRequest {
376 model: budget.model.clone(),
377 messages: vec![ChatMessage::user(transcript)],
378 tools: Vec::new(),
379 max_tokens: 4096,
380 temperature: 0.2,
381 system_prompt: Some(SUMMARIZER_SYSTEM_PROMPT.to_string()),
382 stop_sequences: Vec::new(),
383 tool_choice: nexo_llm::ToolChoice::None,
384 system_blocks: Vec::new(),
385 cache_tools: false,
386 };
387 let response = self
388 .llm
389 .chat(req)
390 .await
391 .map_err(|e| CompactionError::LlmFailed(e.to_string()))?;
392 let summary = match response.content {
393 ResponseContent::Text(t) => t,
394 ResponseContent::ToolCalls(_) => {
395 return Err(CompactionError::LlmFailed(
396 "summarizer returned tool calls instead of text".to_string(),
397 ))
398 }
399 };
400 if summary.trim().is_empty() {
401 return Err(CompactionError::LlmFailed(
402 "summarizer returned empty text".to_string(),
403 ));
404 }
405 Ok(CompactionResult {
406 summary,
407 tail_start_index,
408 head_turns_summarized: head.len(),
409 input_tokens: response.usage.prompt_tokens,
410 output_tokens: response.usage.completion_tokens,
411 })
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use async_trait::async_trait;
419 use chrono::Utc;
420 use futures::stream::BoxStream;
421 use nexo_llm::{ChatResponse, FinishReason, LlmError, TokenUsage};
422
423 fn turn(role: Role, content: &str) -> Interaction {
424 Interaction {
425 role,
426 content: content.into(),
427 timestamp: Utc::now(),
428 }
429 }
430
431 #[test]
432 fn boundary_returns_none_for_empty_history() {
433 assert_eq!(find_safe_boundary(&[], 100), None);
434 }
435
436 #[test]
437 fn boundary_returns_none_when_history_smaller_than_tail_target() {
438 let h = vec![turn(Role::User, "hi"), turn(Role::Assistant, "hello")];
439 assert_eq!(find_safe_boundary(&h, 1000), None);
440 }
441
442 #[test]
443 fn boundary_picks_first_index_meeting_tail_target() {
444 let body = "x".repeat(50);
447 let h: Vec<_> = (0..4)
448 .map(|i| {
449 turn(
450 if i % 2 == 0 {
451 Role::User
452 } else {
453 Role::Assistant
454 },
455 &body,
456 )
457 })
458 .collect();
459 let idx = find_safe_boundary(&h, 100).unwrap();
460 assert_eq!(idx, 2);
461 }
462
463 #[test]
464 fn boundary_returns_none_when_first_turn_alone_meets_tail_target() {
465 let body = "y".repeat(1000);
467 let h = vec![turn(Role::User, &body)];
468 assert_eq!(find_safe_boundary(&h, 100), None);
469 }
470
471 #[test]
472 fn boundary_returns_none_when_two_huge_turns_satisfy_tail_alone() {
473 let body = "z".repeat(5000);
477 let h = vec![turn(Role::User, &body), turn(Role::Assistant, &body)];
478 let idx = find_safe_boundary(&h, 1000).unwrap();
479 assert_eq!(idx, 1);
480 }
481
482 #[test]
483 fn truncate_large_tool_results_replaces_only_oversized() {
484 let mut msgs = vec![
485 ChatMessage::user("hi"),
486 ChatMessage::tool_result("c1", "fetch", "small ok"),
487 ChatMessage::tool_result("c2", "scan", "z".repeat(5000)),
488 ];
489 let n = truncate_large_tool_results(&mut msgs, 200);
490 assert_eq!(n, 1, "only the oversized tool_result should be truncated");
491 assert_eq!(msgs[1].content, "small ok");
492 assert!(msgs[2].content.contains("[truncated"));
493 assert!(
494 msgs[2].content.len() <= 300,
495 "got {}",
496 msgs[2].content.len()
497 );
498 }
499
500 #[test]
501 fn truncate_skips_non_tool_messages() {
502 let mut msgs = vec![
503 ChatMessage::user("z".repeat(5000)),
504 ChatMessage::assistant("z".repeat(5000)),
505 ];
506 let n = truncate_large_tool_results(&mut msgs, 100);
507 assert_eq!(n, 0);
508 assert_eq!(msgs[0].content.len(), 5000);
509 }
510
511 #[test]
512 fn microcompact_clears_only_large_compactable_tool_results() {
513 let mut msgs = vec![
514 ChatMessage::tool_result("c1", "Bash", "x".repeat(5000)),
515 ChatMessage::tool_result("c2", "UnknownTool", "y".repeat(5000)),
516 ChatMessage::tool_result("c3", "Grep", "small"),
517 ];
518
519 let stats = clear_large_compactable_tool_results(&mut msgs, 1024);
520
521 assert_eq!(stats.compacted, 1);
522 assert_eq!(msgs[0].content, MICROCOMPACT_CLEARED_MESSAGE);
523 assert_eq!(msgs[0].tool_call_id.as_deref(), Some("c1"));
524 assert_eq!(msgs[0].name.as_deref(), Some("Bash"));
525 assert_eq!(msgs[1].content.len(), 5000);
526 assert_eq!(msgs[2].content, "small");
527 }
528
529 #[test]
530 fn microcompact_is_idempotent_for_already_cleared_results() {
531 let mut msgs = vec![ChatMessage::tool_result(
532 "c1",
533 "Grep",
534 MICROCOMPACT_CLEARED_MESSAGE,
535 )];
536
537 let stats = clear_large_compactable_tool_results(&mut msgs, 1);
538
539 assert_eq!(stats.compacted, 0);
540 assert_eq!(msgs[0].content, MICROCOMPACT_CLEARED_MESSAGE);
541 assert_eq!(msgs[0].tool_call_id.as_deref(), Some("c1"));
542 }
543
544 struct StubLlm {
546 reply: String,
547 prompt_tokens: u32,
548 completion_tokens: u32,
549 }
550 #[async_trait]
551 impl LlmClient for StubLlm {
552 async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
553 Ok(ChatResponse {
554 content: ResponseContent::Text(self.reply.clone()),
555 usage: TokenUsage {
556 prompt_tokens: self.prompt_tokens,
557 completion_tokens: self.completion_tokens,
558 },
559 finish_reason: FinishReason::Stop,
560 cache_usage: None,
561 })
562 }
563 fn provider(&self) -> &str {
564 "stub"
565 }
566 fn model_id(&self) -> &str {
567 "stub-1"
568 }
569 async fn stream<'a>(
570 &'a self,
571 _req: ChatRequest,
572 ) -> anyhow::Result<BoxStream<'a, anyhow::Result<nexo_llm::StreamChunk>>> {
573 anyhow::bail!("stream not implemented in stub")
574 }
575 }
576
577 struct ErrLlm;
579 #[async_trait]
580 impl LlmClient for ErrLlm {
581 async fn chat(&self, _req: ChatRequest) -> anyhow::Result<ChatResponse> {
582 Err(LlmError::Other(anyhow::anyhow!("kaboom")).into())
583 }
584 fn provider(&self) -> &str {
585 "stub"
586 }
587 fn model_id(&self) -> &str {
588 "stub-1"
589 }
590 async fn stream<'a>(
591 &'a self,
592 _req: ChatRequest,
593 ) -> anyhow::Result<BoxStream<'a, anyhow::Result<nexo_llm::StreamChunk>>> {
594 anyhow::bail!("stream not implemented in stub")
595 }
596 }
597
598 #[tokio::test]
599 async fn compact_happy_path_returns_summary() {
600 let llm = Arc::new(StubLlm {
601 reply: "Compacted: discussed weather.".into(),
602 prompt_tokens: 1500,
603 completion_tokens: 80,
604 });
605 let compactor = LlmCompactor::new(llm);
606 let history = vec![
607 turn(Role::User, "what's the weather"),
608 turn(Role::Assistant, "sunny in Medellin"),
609 turn(Role::User, "and tomorrow?"),
610 turn(Role::Assistant, "rain expected"),
611 ];
612 let budget = CompactionBudget {
613 target_tokens: 2000,
614 tail_keep_tokens: 0,
615 model: "stub-1".into(),
616 };
617 let result = compactor.compact(&history, 2, &budget).await.unwrap();
618 assert!(result.summary.contains("Compacted"));
619 assert_eq!(result.tail_start_index, 2);
620 assert_eq!(result.head_turns_summarized, 2);
621 assert_eq!(result.input_tokens, 1500);
622 assert_eq!(result.output_tokens, 80);
623 }
624
625 #[tokio::test]
626 async fn compact_rejects_zero_boundary() {
627 let llm = Arc::new(StubLlm {
628 reply: "x".into(),
629 prompt_tokens: 0,
630 completion_tokens: 0,
631 });
632 let compactor = LlmCompactor::new(llm);
633 let history = vec![turn(Role::User, "hi")];
634 let budget = CompactionBudget {
635 target_tokens: 0,
636 tail_keep_tokens: 0,
637 model: "stub-1".into(),
638 };
639 let err = compactor.compact(&history, 0, &budget).await.unwrap_err();
640 assert!(matches!(err, CompactionError::NoBoundary));
641 }
642
643 #[tokio::test]
644 async fn compact_rejects_empty_summary() {
645 let llm = Arc::new(StubLlm {
646 reply: " ".into(),
647 prompt_tokens: 10,
648 completion_tokens: 0,
649 });
650 let compactor = LlmCompactor::new(llm);
651 let history = vec![
652 turn(Role::User, "hi"),
653 turn(Role::Assistant, "hello"),
654 turn(Role::User, "tail"),
655 ];
656 let budget = CompactionBudget {
657 target_tokens: 0,
658 tail_keep_tokens: 0,
659 model: "stub-1".into(),
660 };
661 let err = compactor.compact(&history, 2, &budget).await.unwrap_err();
662 assert!(matches!(err, CompactionError::LlmFailed(_)));
663 }
664
665 #[tokio::test]
666 async fn compact_propagates_llm_error() {
667 let compactor = LlmCompactor::new(Arc::new(ErrLlm));
668 let history = vec![
669 turn(Role::User, "hi"),
670 turn(Role::Assistant, "hello"),
671 turn(Role::User, "tail"),
672 ];
673 let budget = CompactionBudget {
674 target_tokens: 0,
675 tail_keep_tokens: 0,
676 model: "stub-1".into(),
677 };
678 let err = compactor.compact(&history, 2, &budget).await.unwrap_err();
679 assert!(matches!(err, CompactionError::LlmFailed(_)));
680 }
681
682 #[test]
683 fn summarizer_prompt_includes_required_rules() {
684 assert!(SUMMARIZER_SYSTEM_PROMPT.contains("Identifiers, paths"));
687 assert!(SUMMARIZER_SYSTEM_PROMPT.contains("FORBIDDEN"));
688 assert!(SUMMARIZER_SYSTEM_PROMPT.contains("Active tasks"));
689 assert!(SUMMARIZER_SYSTEM_PROMPT.contains("most recent explicit request"));
690 }
691}