1use super::{ContextState, Msg, Summary};
16use crate::state::now_ms;
17use serde_json::{Value, json};
18
19#[derive(Debug, Clone)]
21pub struct CompactionRequest {
22 pub fold: usize,
24 pub system: String,
26 pub input: String,
28 pub output_schema: Value,
30 pub version: u64,
32}
33
34pub fn summary_schema() -> Value {
36 json!({
37 "type": "object",
38 "properties": {
39 "goals": {"type": "array", "items": {"type": "string"}},
40 "decisions": {"type": "array", "items": {"type": "string"}},
41 "open": {"type": "array", "items": {"type": "string"}},
42 "facts": {"type": "array", "items": {"type": "string"}},
43 "narrative": {"type": "string"}
44 },
45 "required": ["goals", "decisions", "open", "facts"],
46 "additionalProperties": false
47 })
48}
49
50const SUMMARIZER_SYSTEM: &str = "You compact an agent's conversation memory. Read the transcript excerpt and \
51produce a faithful structured summary: goals (what is being pursued), decisions (what was decided and why), \
52open (unresolved questions, pending work, promises made), facts (concrete facts, values, identifiers, results \
53worth remembering). Keep entries short and specific; never invent; keep identifiers, numbers and names verbatim. \
54Reply with ONLY one JSON object matching the schema.";
55
56pub fn plan_compaction(
61 ctx: &ContextState,
62 keep_last: usize,
63 target_tokens: Option<u64>,
64) -> Option<CompactionRequest> {
65 let n = ctx.messages.len();
66 if n < keep_last + 2 {
67 return None;
68 }
69 let mut fold = n - keep_last;
70 if let Some(target) = target_tokens {
73 let mut kept: u64 = ctx.messages[fold..].iter().map(Msg::est_tokens).sum();
74 while kept > target && n - fold > 2 {
75 kept -= ctx.messages[fold].est_tokens();
76 fold += 1;
77 }
78 }
79 while fold > 0 && matches!(ctx.messages.get(fold), Some(Msg::Tool { .. })) {
82 fold -= 1;
83 }
84 if fold == 0 {
85 return None;
86 }
87 let mut input = String::new();
88 if !ctx.summary.is_empty() {
89 input.push_str("Previous summary (already compacted; extend it, do not lose it):\n");
90 input.push_str(&ctx.summary.render());
91 input.push('\n');
92 }
93 input.push_str("Transcript excerpt to compact:\n");
94 for m in &ctx.messages[..fold] {
95 input.push_str(&render_for_summary(m));
96 input.push('\n');
97 }
98 Some(CompactionRequest {
99 fold,
100 system: SUMMARIZER_SYSTEM.to_string(),
101 input,
102 output_schema: summary_schema(),
103 version: ctx.version,
104 })
105}
106
107fn render_for_summary(m: &Msg) -> String {
108 const CAP: usize = 2000;
109 let clip = |s: &str| {
110 if s.chars().count() > CAP {
111 format!("{}…", s.chars().take(CAP).collect::<String>())
112 } else {
113 s.to_string()
114 }
115 };
116 match m {
117 Msg::System { text, .. } => format!("[system] {}", clip(text)),
118 Msg::Note { text, .. } => format!("[note] {}", clip(text)),
119 Msg::User {
120 text, principal, ..
121 } => format!(
122 "[user{}] {}",
123 principal
124 .as_deref()
125 .map(|p| format!(" {p}"))
126 .unwrap_or_default(),
127 clip(text)
128 ),
129 Msg::Assistant {
130 text, tool_calls, ..
131 } => {
132 let calls: Vec<String> = tool_calls
133 .iter()
134 .map(|c| format!("{}({})", c.name, clip(&c.arguments.to_string())))
135 .collect();
136 format!(
137 "[assistant] {}{}",
138 clip(text.as_deref().unwrap_or("")),
139 if calls.is_empty() {
140 String::new()
141 } else {
142 format!(" calls: {}", calls.join(", "))
143 }
144 )
145 }
146 Msg::Tool {
147 name,
148 content,
149 is_error,
150 ..
151 } => {
152 format!(
153 "[tool {name}{}] {}",
154 if *is_error { " error" } else { "" },
155 clip(&content.to_string())
156 )
157 }
158 }
159}
160
161pub fn apply_compaction(
166 ctx: &mut ContextState,
167 req: &CompactionRequest,
168 verdict: &Value,
169) -> Result<CompactionOutcome, String> {
170 if ctx.version != req.version {
171 return Err(format!(
172 "context version moved from {} to {} during compaction",
173 req.version, ctx.version
174 ));
175 }
176 if req.fold > ctx.messages.len() {
177 return Err("compaction fold exceeds the message count".into());
178 }
179 let mut newer: Summary = match verdict {
180 Value::Object(_) => serde_json::from_value(verdict.clone())
181 .map_err(|e| format!("summary does not match the schema: {e}"))?,
182 Value::String(s) => Summary {
183 narrative: Some(s.clone()),
184 ..Default::default()
185 },
186 _ => return Err("summary verdict must be an object".into()),
187 };
188 newer.covers_messages = req.fold as u64;
189 newer.updated = now_ms();
190 let before_tokens = ctx.est_tokens;
191 ctx.summary.absorb(newer);
192 ctx.messages.drain(..req.fold);
193 ctx.version += 1;
194 ctx.recount();
195 ctx.touch();
196 Ok(CompactionOutcome {
197 folded: req.fold,
198 version: ctx.version,
199 before_tokens,
200 after_tokens: ctx.est_tokens,
201 })
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct CompactionOutcome {
206 pub folded: usize,
207 pub version: u64,
208 pub before_tokens: u64,
209 pub after_tokens: u64,
210}
211
212pub fn apply_fallback(
216 ctx: &mut ContextState,
217 req: &CompactionRequest,
218) -> Result<CompactionOutcome, String> {
219 let mut lines: Vec<String> = ctx.messages[..req.fold.min(ctx.messages.len())]
220 .iter()
221 .map(render_for_summary)
222 .collect();
223 let mut narrative = lines.join("\n");
224 while narrative.len() > 8_000 && lines.len() > 1 {
225 lines.remove(0);
226 narrative = format!("(earlier messages elided)\n{}", lines.join("\n"));
227 }
228 apply_compaction(ctx, req, &Value::String(narrative))
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::context::ContextKind;
235 use crate::wire::intel::ToolCall;
236
237 fn ctx_with(n: usize) -> ContextState {
238 let mut c = ContextState::new(ContextKind::Conversation, 1000);
239 for i in 0..n {
240 c.append(Msg::user(
241 format!("message number {i} with some words in it"),
242 None,
243 ));
244 }
245 c
246 }
247
248 #[test]
249 fn plan_keeps_the_tail_and_does_not_split_tool_rounds() {
250 let mut c = ctx_with(6);
251 c.append(Msg::assistant(
252 None,
253 vec![ToolCall {
254 id: "c1".into(),
255 name: "memory.get".into(),
256 arguments: json!({"key": "k"}),
257 }],
258 ));
259 c.append(Msg::tool(
260 "c1",
261 "memory.get",
262 json!({"found": false}),
263 false,
264 ));
265 c.append(Msg::assistant(Some("done".into()), vec![]));
266 let req = plan_compaction(&c, 2, None).unwrap();
268 assert_eq!(req.fold, 6);
269 assert!(req.input.contains("[user] message number 0"));
270 assert!(
271 !req.input.contains("memory.get"),
272 "the tool round stays verbatim"
273 );
274 assert!(
275 plan_compaction(&ctx_with(3), 2, None).is_none(),
276 "too short to fold"
277 );
278 let big = ctx_with(20);
280 let req = plan_compaction(&big, 10, Some(1)).unwrap();
281 assert_eq!(req.fold, 18);
282 }
283
284 #[test]
285 fn apply_absorbs_the_summary_bumps_version_and_recounts() {
286 let mut c = ctx_with(10);
287 c.plan = Some(super::super::plan::Plan::create("goal", &[json!("a")], 32).unwrap());
288 c.load_skill("review", "h", 8).unwrap();
289 let before = c.est_tokens;
290 let req = plan_compaction(&c, 3, None).unwrap();
291 let out = apply_compaction(
292 &mut c,
293 &req,
294 &json!({"goals": ["finish"], "decisions": [], "open": ["q1"], "facts": ["n=7"]}),
295 )
296 .unwrap();
297 assert_eq!(out.folded, 7);
298 assert_eq!(out.version, 2);
299 assert_eq!(c.messages.len(), 3);
300 assert_eq!(c.summary.goals, vec!["finish".to_string()]);
301 assert_eq!(c.summary.covers_messages, 7);
302 assert!(c.est_tokens < before);
303 assert!(c.plan.is_some(), "plan kept verbatim");
304 assert_eq!(c.skills.len(), 1, "skill names kept");
305 assert!(c.dirty);
306 let req2 = plan_compaction(&ctx_with(10), 3, None).unwrap();
308 assert!(apply_compaction(&mut c, &req2, &json!({})).is_err());
309 let mut c2 = ctx_with(10);
311 let req = plan_compaction(&c2, 3, None).unwrap();
312 let out = apply_fallback(&mut c2, &req).unwrap();
313 assert_eq!(out.folded, 7);
314 assert!(
315 c2.summary
316 .narrative
317 .as_deref()
318 .unwrap()
319 .contains("message number 0")
320 );
321 let wire = c.to_wire();
323 assert!(
324 matches!(&wire[0], crate::wire::intel::Message::System(s) if s.starts_with("Summary of earlier"))
325 );
326 assert!(
327 matches!(&wire[1], crate::wire::intel::Message::System(s) if s.starts_with("Plan ("))
328 );
329 }
330}