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("<");
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}