//! Summary output cleaning and carrier formatting. //! //! Moved verbatim from `kigi-chat-state`'s `compaction_utils`. Covers: //! //! - cleaning the compaction model's raw output ([`format_compact_summary`]), //! - the kigi continuation carrier ([`format_compact_summary_content`]), //! - the canonical `` wrapping ([`wrap_user_query`]). /// Clean the compaction model's raw output into the plain-text `Summary:` /// block that seeds the next turn. /// /// Drafting scratchpad (a top-level `` block, or a nested /// ``/`` wrapper / untagged markdown "**Analysis**" header /// inside the summary) is stripped; control tokens echoed *within* the body /// (the model sometimes quotes its own instruction under section 6) are /// neutralized so they can't prime the next turn to re-emit a `` /// block. A summary that already leads with a numbered section is preserved /// verbatim even when it quotes ``/`` in a later section. pub fn format_compact_summary(summary: &str) -> String { let mut result = summary.to_string(); // 1. Remove leading drafting block(s). A block is // only stripped when it is a genuinely LEADING scratchpad: top-level // (before any ) or immediately after the open modulo // whitespace (nested). An quoted mid-body — after real // sections, e.g. a section-6 instruction echo — is NOT leading and is // left for step 3 to neutralize, so neither a balanced body quote // spanning sections nor an unclosed one ever deletes real content. The // loop peels successive leading blocks should the model emit more than // one. while let Some(start) = result.find("") { let is_leading = match result.find("") { Some(sp) => start < sp || result[sp + "".len()..start].trim().is_empty(), None => result[..start].trim().is_empty(), }; if !is_leading { break; } match result[start..].find("") { Some(rel) => { let end = start + rel + "".len(); result = format!("{}{}", &result[..start], &result[end..]); } None => { // Unclosed leading : drop up to the next // (preserving a summary that follows) or to the end (truncation). let drop_to = result[start..] .find("") .map_or(result.len(), |rel| start + rel); result = format!("{}{}", &result[..start], &result[drop_to..]); break; } } } // 2. Convert the outer to "Summary:\n{inner}", keeping // any text outside the wrapper. `rfind` matches the outer close, so a // literal "" echoed in the body does not truncate the summary; // `end > start` guards a malformed "" order. Leading // scratchpad inside the block is peeled (see `strip_leading_scratchpad`); // a body echo that quotes the instruction is left for step 3 to defuse. if let Some(start) = result.find("") && let Some(end) = result.rfind("") && end > start { let before = result[..start].to_string(); let after = result[end + "".len()..].to_string(); let inner = strip_leading_scratchpad(result[start + "".len()..end].trim()); result = format!("{before}Summary:\n{inner}{after}"); } // 3. Defuse any compaction-control tokens still echoed inside the body so the // seed can't prime the next turn to re-emit a block. result = neutralize_compaction_control_tokens(&result); // Collapse excessive blank lines (3+ newlines → 2) while result.contains("\n\n\n") { result = result.replace("\n\n\n", "\n\n"); } result.trim().to_string() } /// Peel leading drafting scratchpad off an extracted `` block. /// /// A markdown "**Analysis**"-style header has no opening `` tag for /// step 1 to catch; it ends at an orphan ``. Everything up to and /// including the *last* `` is dropped, so a scratchpad that itself /// quotes `` mid-reasoning is still removed whole. The peel is /// skipped when the block already starts with a numbered section — including a /// markdown-decorated one like `## 1.` or `**1.**` — so a `` merely /// echoed inside a real section never truncates the summary. Any leftover /// leading `` wrapper is then unwrapped. fn strip_leading_scratchpad(inner: &str) -> String { let mut s = inner.trim(); let lead = s.trim_start_matches(['#', '*', '-', '>', ' ', '\t']); if !lead.starts_with(|c: char| c.is_ascii_digit()) && let Some(pos) = s.rfind("") { s = s[pos + "".len()..].trim_start(); } if let Some(rest) = s.strip_prefix("") { s = rest.trim_start(); } s.to_string() } /// Defuse compaction-control tokens echoed inside a summary body by inserting /// a zero-width space after `<`, so they can't be read as live tags by the next /// turn. Closers first so the inserted sentinel never re-matches. fn neutralize_compaction_control_tokens(text: &str) -> String { text.replace("", "<\u{200b}/summary>") .replace("", "<\u{200b}summary>") .replace("", "<\u{200b}/analysis>") .replace("", "<\u{200b}analysis>") .replace("", "<\u{200b}/summary_request>") .replace("", "<\u{200b}summary_request>") } /// True when the cleaned summary seed is too small to plausibly carry the /// task state of the conversation it would replace. Callers should /// retry like a transient failure. pub fn is_degenerate_summary(raw_summary: &str) -> bool { format_compact_summary(raw_summary).chars().count() < super::config::MIN_SUMMARY_SEED_CHARS } /// Clean tags via [`format_compact_summary`] and prepend the continuation /// preamble. This is the user message content that replaces the compacted /// conversation. pub fn format_compact_summary_content(raw_summary: &str) -> String { let cleaned = format_compact_summary(raw_summary); format!( "This session is being continued from a previous conversation that ran out of context. \ The summary below covers the earlier portion of the conversation.\n\n{cleaned}" ) } /// Wrap text in `...` tags. /// /// This is the canonical wrapping used for user messages that contain /// a query or compaction summary. Centralised here so all harnesses /// share the same format. pub fn wrap_user_query(text: impl Into) -> String { let text = text.into(); format!("\n{text}\n") } #[cfg(test)] mod tests { use super::*; #[test] fn degenerate_summary_below_min_seed_chars() { let raw = "\n1. Primary Request: q\n"; assert!(is_degenerate_summary(raw)); let long = format!( "\n1. Primary Request: q\n{}\n", "y".repeat(500) ); assert!(!is_degenerate_summary(&long)); } #[test] fn strips_analysis_keeps_summary() { let input = "\nThinking about the problem...\n\n\n\n1. Primary Request: Fix the bug\n"; let result = format_compact_summary(input); assert!(!result.contains("Thinking about the problem")); assert!(result.contains("Summary:\n1. Primary Request: Fix the bug")); assert!(!result.contains("")); assert!(!result.contains("")); } #[test] fn no_tags_passthrough() { assert_eq!( format_compact_summary("Just plain text summary."), "Just plain text summary." ); } #[test] fn only_summary_becomes_heading() { let result = format_compact_summary("\n1. Request: Do something\n"); assert_eq!(result, "Summary:\n1. Request: Do something"); } #[test] fn collapses_blank_lines() { let input = "\nThought\n\n\n\n\n\nResult\n"; assert!(!format_compact_summary(input).contains("\n\n\n")); } #[test] fn unclosed_analysis_strips_remainder() { assert_eq!( format_compact_summary("\nPartial reasoning about the task..."), "" ); } #[test] fn keeps_sections_on_section6_instruction_echo() { // The model echoes the summarization instruction under section 6, // which would otherwise seed the next turn to re-emit a stray block. let raw = "\n1. Primary Request and Intent: build app\n2. Key Technical Concepts: webgl\n6. All user messages: 'respond with ONLY the block.'\n9. Optional Next Step: rerun\n"; let result = format_compact_summary(raw); for needle in [ "1. Primary Request", "2. Key Technical Concepts", "9. Optional Next Step", ] { assert!(result.contains(needle), "dropped {needle:?}: {result:?}"); } assert!(!result.contains(""), "live : {result:?}"); assert!( !result.contains(""), "live : {result:?}" ); } #[test] fn unclosed_summary_open_preserves_body() { let input = "\n1. Primary Request: do the thing\n9. Optional Next Step: continue"; let result = format_compact_summary(input); assert!(result.contains("1. Primary Request: do the thing")); assert!(result.contains("9. Optional Next Step: continue")); assert!(!result.contains("")); } #[test] fn multibyte_adjacent_to_tags_no_panic() { let raw = "1. Primary Request: ship 🚀 to 北京\n9. Optional Next Step: 完成"; let result = format_compact_summary(raw); assert!(result.starts_with("Summary:\n1. Primary Request: ship 🚀 to 北京")); assert!(result.contains("9. Optional Next Step: 完成")); } #[test] fn malformed_tag_order_does_not_panic() { let result = format_compact_summary("intro middle tail"); assert!(!result.contains("")); assert!(!result.contains("")); assert!(result.contains("intro")); assert!(result.contains("tail")); } #[test] fn content_adds_preamble_and_cleans() { let result = format_compact_summary_content( "\nThinking\n\n\n\n1. Fix bug\n", ); assert!(result.starts_with("This session is being continued")); assert!(result.contains("Summary:\n1. Fix bug")); assert!(!result.contains("Thinking")); assert!(!result.contains("")); } #[test] fn wrap_user_query_wraps_text() { assert_eq!( wrap_user_query("hello world"), "\nhello world\n" ); } }