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,
35}
36
37pub fn summary_schema() -> Value {
41 json!({
42 "type": "object",
43 "properties": {
44 "goals": {"type": "array", "items": {"type": "string"}},
45 "decisions": {"type": "array", "items": {"type": "string"}},
46 "open": {"type": "array", "items": {"type": "string"}},
47 "facts": {"type": "array", "items": {"type": "string"}},
48 "narrative": {"type": "string"}
49 },
50 "required": ["goals", "decisions", "open", "facts"],
51 "additionalProperties": false
52 })
53}
54
55const SUMMARIZER_SYSTEM: &str = "You compact an agent's conversation memory. Read the transcript excerpt and \
56produce a faithful structured summary: goals (what is being pursued), decisions (what was decided and why), \
57open (unresolved questions, pending work, promises made), facts (concrete facts, values, identifiers, results \
58worth remembering). Keep entries short and specific; never invent; keep identifiers, numbers and names verbatim. \
59Reply with ONLY one JSON object matching the schema.";
60
61pub fn plan_compaction(
67 ctx: &ContextState,
68 keep_last: usize,
69 target_tokens: Option<u64>,
70) -> Option<CompactionRequest> {
71 let n = ctx.messages.len();
72 if n < keep_last + 2 {
73 return None;
74 }
75 let mut fold = n - keep_last;
76 if let Some(target) = target_tokens {
79 let mut kept: u64 = ctx.messages[fold..].iter().map(Msg::est_tokens).sum();
80 while kept > target && n - fold > 2 {
81 kept -= ctx.messages[fold].est_tokens();
82 fold += 1;
83 }
84 }
85 while fold > 0 && ctx.messages.get(fold).is_some_and(|m| !m.is_user()) {
101 fold -= 1;
102 }
103 if fold == 0 {
107 return None;
108 }
109 let mut input = String::new();
110 if !ctx.summary.is_empty() {
111 input.push_str("Previous summary (already compacted; extend it, do not lose it):\n");
112 input.push_str(&ctx.summary.render());
113 input.push('\n');
114 }
115 input.push_str("Transcript excerpt to compact:\n");
116 for m in &ctx.messages[..fold] {
117 input.push_str(&render_for_summary(m));
118 input.push('\n');
119 }
120 Some(CompactionRequest {
121 fold,
122 system: SUMMARIZER_SYSTEM.to_string(),
123 input,
124 output_schema: summary_schema(),
125 version: ctx.version,
126 })
127}
128
129fn render_for_summary(m: &Msg) -> String {
130 const CAP: usize = 2000;
131 let clip = |s: &str| {
132 if s.chars().count() > CAP {
133 format!("{}…", s.chars().take(CAP).collect::<String>())
134 } else {
135 s.to_string()
136 }
137 };
138 match m {
139 Msg::System { text, .. } => format!("[system] {}", clip(text)),
140 Msg::Note { text, .. } => format!("[note] {}", clip(text)),
141 Msg::User {
142 text, principal, ..
143 } => format!(
144 "[user{}] {}",
145 principal
146 .as_deref()
147 .map(|p| format!(" {p}"))
148 .unwrap_or_default(),
149 clip(text)
150 ),
151 Msg::Assistant {
152 text, tool_calls, ..
153 } => {
154 let calls: Vec<String> = tool_calls
155 .iter()
156 .map(|c| format!("{}({})", c.name, clip(&c.arguments.to_string())))
157 .collect();
158 format!(
159 "[assistant] {}{}",
160 clip(text.as_deref().unwrap_or("")),
161 if calls.is_empty() {
162 String::new()
163 } else {
164 format!(" calls: {}", calls.join(", "))
165 }
166 )
167 }
168 Msg::Tool {
169 name,
170 content,
171 is_error,
172 ..
173 } => {
174 format!(
175 "[tool {name}{}] {}",
176 if *is_error { " error" } else { "" },
177 clip(&content.to_string())
178 )
179 }
180 }
181}
182
183pub fn apply_compaction(
189 ctx: &mut ContextState,
190 req: &CompactionRequest,
191 verdict: &Value,
192) -> Result<CompactionOutcome, String> {
193 if ctx.version != req.version {
194 return Err(format!(
195 "context version moved from {} to {} during compaction",
196 req.version, ctx.version
197 ));
198 }
199 if req.fold > ctx.messages.len() {
200 return Err("compaction fold exceeds the message count".into());
201 }
202 let mut newer: Summary = match verdict {
203 Value::Object(_) => serde_json::from_value(verdict.clone())
204 .map_err(|e| format!("summary does not match the schema: {e}"))?,
205 Value::String(s) => Summary {
206 narrative: Some(s.clone()),
207 ..Default::default()
208 },
209 _ => return Err("summary verdict must be an object".into()),
210 };
211 newer.covers_messages = req.fold as u64;
212 newer.updated = now_ms();
213 let before_tokens = ctx.est_tokens;
214 ctx.summary.absorb(newer);
215 ctx.messages.drain(..req.fold);
216 ctx.version += 1;
217 ctx.recount();
218 ctx.touch();
219 Ok(CompactionOutcome {
220 folded: req.fold,
221 version: ctx.version,
222 before_tokens,
223 after_tokens: ctx.est_tokens,
224 })
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct CompactionOutcome {
229 pub folded: usize,
230 pub version: u64,
231 pub before_tokens: u64,
232 pub after_tokens: u64,
233}
234
235pub fn apply_fallback(
239 ctx: &mut ContextState,
240 req: &CompactionRequest,
241) -> Result<CompactionOutcome, String> {
242 let mut lines: Vec<String> = ctx.messages[..req.fold.min(ctx.messages.len())]
243 .iter()
244 .map(render_for_summary)
245 .collect();
246 let mut narrative = lines.join("\n");
247 while narrative.len() > 8_000 && lines.len() > 1 {
248 lines.remove(0);
249 narrative = format!("(earlier messages elided)\n{}", lines.join("\n"));
250 }
251 apply_compaction(ctx, req, &Value::String(narrative))
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::context::ContextKind;
258 use crate::wire::intel::ToolCall;
259
260 fn ctx_with(n: usize) -> ContextState {
261 let mut c = ContextState::new(ContextKind::Conversation, 1000);
262 for i in 0..n {
263 c.append(Msg::user(
264 format!("message number {i} with some words in it"),
265 None,
266 ));
267 }
268 c
269 }
270
271 #[test]
272 fn plan_keeps_the_tail_and_does_not_split_tool_rounds() {
273 let mut c = ctx_with(6);
274 c.append(Msg::assistant(
275 None,
276 vec![ToolCall {
277 id: "c1".into(),
278 name: "memory.get".into(),
279 arguments: json!({"key": "k"}),
280 }],
281 ));
282 c.append(Msg::tool(
283 "c1",
284 "memory.get",
285 json!({"found": false}),
286 false,
287 ));
288 c.append(Msg::assistant(Some("done".into()), vec![]));
289 let req = plan_compaction(&c, 2, None).unwrap();
293 assert_eq!(req.fold, 5);
294 assert!(c.messages[req.fold].is_user());
295 assert!(req.input.contains("[user] message number 0"));
296 assert!(req.input.contains("[user] message number 4"));
297 assert!(
298 !req.input.contains("[user] message number 5"),
299 "the user that opened the tool round is kept, not folded"
300 );
301 assert!(
302 !req.input.contains("memory.get"),
303 "the tool round stays verbatim"
304 );
305 assert!(
306 plan_compaction(&ctx_with(3), 2, None).is_none(),
307 "too short to fold"
308 );
309 let big = ctx_with(20);
311 let req = plan_compaction(&big, 10, Some(1)).unwrap();
312 assert_eq!(req.fold, 18);
313 }
314
315 fn mixed_ctx() -> ContextState {
318 let mut c = ContextState::new(ContextKind::Conversation, 1000);
319 let call = |id: &str| ToolCall {
320 id: id.into(),
321 name: "memory.get".into(),
322 arguments: json!({"key": id}),
323 };
324 c.append(Msg::user("first ask with a few words", None));
325 c.append(Msg::assistant(Some("first answer".into()), vec![]));
326 c.append(Msg::user("second ask with a few words", None));
327 c.append(Msg::assistant(None, vec![call("c1")]));
328 c.append(Msg::tool(
329 "c1",
330 "memory.get",
331 json!({"found": false}),
332 false,
333 ));
334 c.append(Msg::assistant(Some("second answer".into()), vec![]));
335 c.append(Msg::user("third ask with a few words", None));
336 c.append(Msg::assistant(None, vec![call("c2"), call("c3")]));
337 c.append(Msg::tool("c2", "memory.get", json!({"found": true}), false));
338 c.append(Msg::tool("c3", "memory.get", json!({"found": true}), false));
339 c.append(Msg::assistant(Some("third answer".into()), vec![]));
340 c
341 }
342
343 #[test]
349 fn fold_never_leaves_an_assistant_or_a_tool_result_first() {
350 let c = mixed_ctx();
351 let req = plan_compaction(&c, 1, None).unwrap();
354 assert_eq!(req.fold, 6);
355 assert!(c.messages[req.fold].is_user());
356 assert_eq!(plan_compaction(&c, 5, None).unwrap().fold, 6);
358 let req = plan_compaction(&c, 7, None).unwrap();
361 assert_eq!(req.fold, 2);
362 assert!(c.messages[req.fold].is_user());
363 let req = plan_compaction(&c, 2, Some(1)).unwrap();
365 assert!(c.messages[req.fold].is_user());
366 let mut none = ContextState::new(ContextKind::Conversation, 1000);
369 for i in 0..6 {
370 none.append(Msg::assistant(Some(format!("thought {i}")), vec![]));
371 }
372 assert!(plan_compaction(&none, 2, None).is_none());
373 }
374
375 #[test]
378 fn every_fold_point_keeps_a_user_first_and_no_orphan_tool_result() {
379 let c = mixed_ctx();
380 let n = c.messages.len();
381 for keep_last in 0..=n {
382 for target in [None, Some(0), Some(1), Some(60), Some(10_000)] {
383 let Some(req) = plan_compaction(&c, keep_last, target) else {
384 continue;
385 };
386 let kept = &c.messages[req.fold..];
387 let Some(first) = kept.first() else {
388 continue; };
390 assert!(
391 first.is_user(),
392 "keep_last {keep_last} target {target:?} → fold {} left {first:?} first",
393 req.fold
394 );
395 let calls: Vec<&str> = kept
397 .iter()
398 .flat_map(|m| match m {
399 Msg::Assistant { tool_calls, .. } => tool_calls.as_slice(),
400 _ => &[],
401 })
402 .map(|tc| tc.id.as_str())
403 .collect();
404 for m in kept {
405 if let Msg::Tool { id, .. } = m {
406 assert!(
407 calls.contains(&id.as_str()),
408 "keep_last {keep_last} target {target:?} → fold {} orphaned {id}",
409 req.fold
410 );
411 }
412 }
413 }
414 }
415 }
416
417 #[test]
418 fn apply_absorbs_the_summary_bumps_version_and_recounts() {
419 let mut c = ctx_with(10);
420 c.plan = Some(super::super::plan::Plan::create("goal", &[json!("a")], 32).unwrap());
421 c.load_skill("review", "h", 8).unwrap();
422 let before = c.est_tokens;
423 let req = plan_compaction(&c, 3, None).unwrap();
424 let out = apply_compaction(
425 &mut c,
426 &req,
427 &json!({"goals": ["finish"], "decisions": [], "open": ["q1"], "facts": ["n=7"]}),
428 )
429 .unwrap();
430 assert_eq!(out.folded, 7);
431 assert_eq!(out.version, 2);
432 assert_eq!(c.messages.len(), 3);
433 assert_eq!(c.summary.goals, vec!["finish".to_string()]);
434 assert_eq!(c.summary.covers_messages, 7);
435 assert!(c.est_tokens < before);
436 assert!(c.plan.is_some(), "plan kept verbatim");
437 assert_eq!(c.skills.len(), 1, "skill names kept");
438 assert!(c.dirty);
439 let req2 = plan_compaction(&ctx_with(10), 3, None).unwrap();
441 assert!(apply_compaction(&mut c, &req2, &json!({})).is_err());
442 let mut c2 = ctx_with(10);
444 let req = plan_compaction(&c2, 3, None).unwrap();
445 let out = apply_fallback(&mut c2, &req).unwrap();
446 assert_eq!(out.folded, 7);
447 assert!(
448 c2.summary
449 .narrative
450 .as_deref()
451 .unwrap()
452 .contains("message number 0")
453 );
454 let wire = c.to_wire();
456 assert!(
457 matches!(&wire[0], crate::wire::intel::Message::System(s) if s.starts_with("Summary of earlier"))
458 );
459 assert!(
460 matches!(&wire[1], crate::wire::intel::Message::System(s) if s.starts_with("Plan ("))
461 );
462 }
463}