Skip to main content

agent_core/core/
compaction.rs

1//! Task 30 — typed compaction summary provenance (spec §9.3).
2//!
3//! A compaction summary is a typed context artifact: it carries provenance
4//! (where it came from, who produced it, under which prompt stack and
5//! disclosure classes) and enters model context through ONE canonical,
6//! sanitized rendering. It is neither ordinary user text nor immutable
7//! system policy:
8//!
9//! - wrapper/escaping injection inside a summary body is neutralized before
10//!   the summary is rendered into context, so hostile summary text can
11//!   never close its `<context-summary>` data boundary or forge
12//!   system-prompt blocks;
13//! - the old system prompt survives as TYPED metadata (the session's
14//!   `system_prompt` field and [`CompactionRecord::prior_system_prompt`]),
15//!   never as a plain user message.
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::core::stream_types::SharedMessage;
22
23/// Version of the persisted summary artifact schema.
24pub const COMPACTION_SUMMARY_SCHEMA_VERSION: u32 = 1;
25
26/// Content classes source material may belong to. Shared vocabulary between
27/// summary provenance (spec §9.3) and the compaction disclosure policy
28/// (spec §9.4).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ContentClass {
32    UserText,
33    AssistantText,
34    Thinking,
35    ToolCalls,
36    ToolResults,
37    /// STRUCTURED path-bearing surfaces only: the file-operations record
38    /// derived from tool calls, and — conservatively — entire tool-call
39    /// argument payloads (withheld wholesale on exclusion, because paths
40    /// inside nested/positional/unrecognized argument shapes cannot be
41    /// reliably identified). Paths mentioned inside free text belong to
42    /// their text class (`UserText`, `AssistantText`, `ToolResults`,
43    /// `EventData`); excluding those classes withholds such mentions.
44    FilePaths,
45    EventData,
46}
47
48impl ContentClass {
49    /// Every known class, in stable order.
50    pub const ALL: [ContentClass; 7] = [
51        ContentClass::UserText,
52        ContentClass::AssistantText,
53        ContentClass::Thinking,
54        ContentClass::ToolCalls,
55        ContentClass::ToolResults,
56        ContentClass::FilePaths,
57        ContentClass::EventData,
58    ];
59
60    /// Canonical snake_case name (matches the serde representation).
61    pub fn as_str(&self) -> &'static str {
62        match self {
63            ContentClass::UserText => "user_text",
64            ContentClass::AssistantText => "assistant_text",
65            ContentClass::Thinking => "thinking",
66            ContentClass::ToolCalls => "tool_calls",
67            ContentClass::ToolResults => "tool_results",
68            ContentClass::FilePaths => "file_paths",
69            ContentClass::EventData => "event_data",
70        }
71    }
72
73    /// Parse a canonical snake_case name. Unknown names return `None` so
74    /// config surfaces can warn instead of silently dropping policy.
75    pub fn parse(s: &str) -> Option<Self> {
76        Self::ALL.iter().copied().find(|c| c.as_str() == s)
77    }
78}
79
80/// Where compaction summarization runs (spec §9.4).
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum CompactionMode {
84    /// Summarize through the configured remote provider (default).
85    #[default]
86    Remote,
87    /// Summarize locally — no HTTP request may be constructed.
88    LocalOnly,
89}
90
91/// Redaction posture applied while serializing the source range for
92/// summarization.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum RedactionPolicy {
96    /// Legacy behavior: tool results and thinking are truncated but no
97    /// content class is excluded.
98    TruncationOnly,
99    /// Configured content classes were excluded per the session disclosure
100    /// policy (spec §9.4).
101    PolicyExclusions,
102}
103
104/// Typed provenance persisted with every compaction summary (spec §9.3).
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct CompactionRecord {
107    /// [`COMPACTION_SUMMARY_SCHEMA_VERSION`] at write time.
108    pub schema_version: u32,
109    /// Session the summarized range came from.
110    pub source_session: String,
111    /// Number of messages in the summarized range.
112    pub source_message_count: usize,
113    /// SHA-256 digest binding the exact summarized message range.
114    pub source_range_digest: String,
115    /// Provider that produced the summary.
116    pub summary_provider: String,
117    /// Model that produced the summary.
118    pub summary_model: String,
119    /// When the summary was produced.
120    pub created_at: DateTime<Utc>,
121    /// SHA-256 digest of the prompt stack the summarizer ran under.
122    pub prompt_stack_digest: String,
123    /// Content classes included in the summarization input.
124    pub included_classes: Vec<ContentClass>,
125    /// Content classes excluded from the summarization input.
126    pub excluded_classes: Vec<ContentClass>,
127    /// Redaction posture applied to the source range.
128    pub redaction_policy: RedactionPolicy,
129    /// The predecessor's system prompt as typed metadata (spec §9.3: never
130    /// embedded as a plain user message).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub prior_system_prompt: Option<String>,
133}
134
135/// SHA-256 digest over the canonical serialization of a message range.
136/// Order-sensitive: the digest binds both content and sequence.
137pub fn message_range_digest(messages: &[SharedMessage]) -> String {
138    let mut hasher = Sha256::new();
139    for msg in messages {
140        // Length-prefix each message so concatenation cannot collide across
141        // message boundaries.
142        let bytes = serde_json::to_vec(&**msg).unwrap_or_default();
143        hasher.update((bytes.len() as u64).to_be_bytes());
144        hasher.update(&bytes);
145    }
146    hex_digest(hasher)
147}
148
149/// SHA-256 digest over the ordered prompt-stack parts used for
150/// summarization (system prompt, instruction template, custom focus).
151pub fn prompt_stack_digest(parts: &[&str]) -> String {
152    let mut hasher = Sha256::new();
153    for part in parts {
154        hasher.update((part.len() as u64).to_be_bytes());
155        hasher.update(part.as_bytes());
156    }
157    hex_digest(hasher)
158}
159
160fn hex_digest(hasher: Sha256) -> String {
161    let digest = hasher.finalize();
162    let mut out = String::with_capacity(64);
163    for byte in digest {
164        use std::fmt::Write;
165        let _ = write!(out, "{byte:02x}");
166    }
167    out
168}
169
170/// Wrapper tags a summary body must never be able to open or close.
171/// Matched case-insensitively on the tag STEM so `</Context-Summary>` and
172/// `<CONTEXT-SUMMARY>` are both neutralized.
173const PROTECTED_TAG_STEMS: [&str; 2] = ["context-summary", "system-prompt"];
174
175/// Neutralize wrapper/escaping injection inside summary text (spec §9.3:
176/// escaping cannot elevate content). Any opening or closing form of a
177/// protected tag has its `<` replaced with the HTML entity, which keeps the
178/// text legible while breaking the markup.
179pub fn sanitize_summary_text(text: &str) -> String {
180    let mut out = String::with_capacity(text.len());
181    let chars: Vec<char> = text.chars().collect();
182    let mut i = 0;
183    while i < chars.len() {
184        if chars[i] == '<' {
185            let mut j = i + 1;
186            if j < chars.len() && chars[j] == '/' {
187                j += 1;
188            }
189            let tail: String = chars[j..(j + 16).min(chars.len())]
190                .iter()
191                .collect::<String>()
192                .to_lowercase();
193            if PROTECTED_TAG_STEMS
194                .iter()
195                .any(|stem| tail.starts_with(stem))
196            {
197                out.push_str("&lt;");
198                i += 1;
199                continue;
200            }
201        }
202        out.push(chars[i]);
203        i += 1;
204    }
205    out
206}
207
208/// The ONE canonical rendering of a compaction summary into model context:
209/// a sanitized `<context-summary>` user message plus the assistant
210/// acknowledgement. Every frontend and both transition policies (linked
211/// successor and in-place) share this shape — that is what makes their
212/// logical histories equivalent.
213pub fn compaction_context_messages(summary_text: &str) -> Vec<SharedMessage> {
214    let sanitized = sanitize_summary_text(summary_text);
215    let user = format!(
216        "The conversation history before this point was compacted into the \
217         following summary:\n\n<context-summary>\n{}\n</context-summary>\n\n\
218         Continue from where we left off. The summary and the system prompt \
219         contain all the context you need.",
220        sanitized
221    );
222    vec![
223        SharedMessage::new(serde_json::json!({"role": "user", "content": user})),
224        SharedMessage::new(serde_json::json!({
225            "role": "assistant",
226            "content": "I've loaded the conversation summary. Ready to continue."
227        })),
228    ]
229}