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(
62 ctx: &ContextState,
63 keep_last: usize,
64 target_tokens: Option<u64>,
65) -> Option<CompactionRequest> {
66 let n = ctx.messages.len();
67 if n < keep_last + 2 {
68 return None;
69 }
70 let mut fold = n - keep_last;
71 if let Some(target) = target_tokens {
74 let mut kept: u64 = ctx.messages[fold..].iter().map(Msg::est_tokens).sum();
75 while kept > target && n - fold > 2 {
76 kept -= ctx.messages[fold].est_tokens();
77 fold += 1;
78 }
79 }
80 while fold > 0 && ctx.messages.get(fold).is_some_and(|m| !m.is_user()) {
96 fold -= 1;
97 }
98 if fold == 0 {
102 return None;
103 }
104 let mut input = String::new();
105 if !ctx.summary.is_empty() {
106 input.push_str("Previous summary (already compacted; extend it, do not lose it):\n");
107 input.push_str(&ctx.summary.render());
108 input.push('\n');
109 }
110 input.push_str("Transcript excerpt to compact:\n");
111 for m in &ctx.messages[..fold] {
112 input.push_str(&render_for_summary(m));
113 input.push('\n');
114 }
115 Some(CompactionRequest {
116 fold,
117 system: SUMMARIZER_SYSTEM.to_string(),
118 input,
119 output_schema: summary_schema(),
120 version: ctx.version,
121 })
122}
123
124fn render_for_summary(m: &Msg) -> String {
125 const CAP: usize = 2000;
126 let clip = |s: &str| {
127 if s.chars().count() > CAP {
128 format!("{}…", s.chars().take(CAP).collect::<String>())
129 } else {
130 s.to_string()
131 }
132 };
133 match m {
134 Msg::System { text, .. } => format!("[system] {}", clip(text)),
135 Msg::Note { text, .. } => format!("[note] {}", clip(text)),
136 Msg::User {
137 text, principal, ..
138 } => format!(
139 "[user{}] {}",
140 principal
141 .as_deref()
142 .map(|p| format!(" {p}"))
143 .unwrap_or_default(),
144 clip(text)
145 ),
146 Msg::Assistant {
147 text, tool_calls, ..
148 } => {
149 let calls: Vec<String> = tool_calls
150 .iter()
151 .map(|c| format!("{}({})", c.name, clip(&c.arguments.to_string())))
152 .collect();
153 format!(
154 "[assistant] {}{}",
155 clip(text.as_deref().unwrap_or("")),
156 if calls.is_empty() {
157 String::new()
158 } else {
159 format!(" calls: {}", calls.join(", "))
160 }
161 )
162 }
163 Msg::Tool {
164 name,
165 content,
166 is_error,
167 ..
168 } => {
169 format!(
170 "[tool {name}{}] {}",
171 if *is_error { " error" } else { "" },
172 clip(&content.to_string())
173 )
174 }
175 }
176}
177
178pub fn apply_compaction(
183 ctx: &mut ContextState,
184 req: &CompactionRequest,
185 verdict: &Value,
186) -> Result<CompactionOutcome, String> {
187 if ctx.version != req.version {
188 return Err(format!(
189 "context version moved from {} to {} during compaction",
190 req.version, ctx.version
191 ));
192 }
193 if req.fold > ctx.messages.len() {
194 return Err("compaction fold exceeds the message count".into());
195 }
196 let mut newer: Summary = match verdict {
197 Value::Object(_) => serde_json::from_value(verdict.clone())
198 .map_err(|e| format!("summary does not match the schema: {e}"))?,
199 Value::String(s) => Summary {
200 narrative: Some(s.clone()),
201 ..Default::default()
202 },
203 _ => return Err("summary verdict must be an object".into()),
204 };
205 newer.covers_messages = req.fold as u64;
206 newer.updated = now_ms();
207 let before_tokens = ctx.est_tokens;
208 ctx.summary.absorb(newer);
209 ctx.messages.drain(..req.fold);
210 ctx.version += 1;
211 ctx.recount();
212 ctx.touch();
213 Ok(CompactionOutcome {
214 folded: req.fold,
215 version: ctx.version,
216 before_tokens,
217 after_tokens: ctx.est_tokens,
218 })
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct CompactionOutcome {
223 pub folded: usize,
224 pub version: u64,
225 pub before_tokens: u64,
226 pub after_tokens: u64,
227}
228
229pub fn apply_fallback(
233 ctx: &mut ContextState,
234 req: &CompactionRequest,
235) -> Result<CompactionOutcome, String> {
236 let mut lines: Vec<String> = ctx.messages[..req.fold.min(ctx.messages.len())]
237 .iter()
238 .map(render_for_summary)
239 .collect();
240 let mut narrative = lines.join("\n");
241 while narrative.len() > 8_000 && lines.len() > 1 {
242 lines.remove(0);
243 narrative = format!("(earlier messages elided)\n{}", lines.join("\n"));
244 }
245 apply_compaction(ctx, req, &Value::String(narrative))
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::context::ContextKind;
252 use crate::wire::intel::ToolCall;
253
254 fn ctx_with(n: usize) -> ContextState {
255 let mut c = ContextState::new(ContextKind::Conversation, 1000);
256 for i in 0..n {
257 c.append(Msg::user(
258 format!("message number {i} with some words in it"),
259 None,
260 ));
261 }
262 c
263 }
264
265 #[test]
266 fn plan_keeps_the_tail_and_does_not_split_tool_rounds() {
267 let mut c = ctx_with(6);
268 c.append(Msg::assistant(
269 None,
270 vec![ToolCall {
271 id: "c1".into(),
272 name: "memory.get".into(),
273 arguments: json!({"key": "k"}),
274 }],
275 ));
276 c.append(Msg::tool(
277 "c1",
278 "memory.get",
279 json!({"found": false}),
280 false,
281 ));
282 c.append(Msg::assistant(Some("done".into()), vec![]));
283 let req = plan_compaction(&c, 2, None).unwrap();
287 assert_eq!(req.fold, 5);
288 assert!(c.messages[req.fold].is_user());
289 assert!(req.input.contains("[user] message number 0"));
290 assert!(req.input.contains("[user] message number 4"));
291 assert!(
292 !req.input.contains("[user] message number 5"),
293 "the user that opened the tool round is kept, not folded"
294 );
295 assert!(
296 !req.input.contains("memory.get"),
297 "the tool round stays verbatim"
298 );
299 assert!(
300 plan_compaction(&ctx_with(3), 2, None).is_none(),
301 "too short to fold"
302 );
303 let big = ctx_with(20);
305 let req = plan_compaction(&big, 10, Some(1)).unwrap();
306 assert_eq!(req.fold, 18);
307 }
308
309 fn mixed_ctx() -> ContextState {
312 let mut c = ContextState::new(ContextKind::Conversation, 1000);
313 let call = |id: &str| ToolCall {
314 id: id.into(),
315 name: "memory.get".into(),
316 arguments: json!({"key": id}),
317 };
318 c.append(Msg::user("first ask with a few words", None));
319 c.append(Msg::assistant(Some("first answer".into()), vec![]));
320 c.append(Msg::user("second ask with a few words", None));
321 c.append(Msg::assistant(None, vec![call("c1")]));
322 c.append(Msg::tool(
323 "c1",
324 "memory.get",
325 json!({"found": false}),
326 false,
327 ));
328 c.append(Msg::assistant(Some("second answer".into()), vec![]));
329 c.append(Msg::user("third ask with a few words", None));
330 c.append(Msg::assistant(None, vec![call("c2"), call("c3")]));
331 c.append(Msg::tool("c2", "memory.get", json!({"found": true}), false));
332 c.append(Msg::tool("c3", "memory.get", json!({"found": true}), false));
333 c.append(Msg::assistant(Some("third answer".into()), vec![]));
334 c
335 }
336
337 #[test]
343 fn fold_never_leaves_an_assistant_or_a_tool_result_first() {
344 let c = mixed_ctx();
345 let req = plan_compaction(&c, 1, None).unwrap();
348 assert_eq!(req.fold, 6);
349 assert!(c.messages[req.fold].is_user());
350 assert_eq!(plan_compaction(&c, 5, None).unwrap().fold, 6);
352 let req = plan_compaction(&c, 7, None).unwrap();
355 assert_eq!(req.fold, 2);
356 assert!(c.messages[req.fold].is_user());
357 let req = plan_compaction(&c, 2, Some(1)).unwrap();
359 assert!(c.messages[req.fold].is_user());
360 let mut none = ContextState::new(ContextKind::Conversation, 1000);
363 for i in 0..6 {
364 none.append(Msg::assistant(Some(format!("thought {i}")), vec![]));
365 }
366 assert!(plan_compaction(&none, 2, None).is_none());
367 }
368
369 #[test]
372 fn every_fold_point_keeps_a_user_first_and_no_orphan_tool_result() {
373 let c = mixed_ctx();
374 let n = c.messages.len();
375 for keep_last in 0..=n {
376 for target in [None, Some(0), Some(1), Some(60), Some(10_000)] {
377 let Some(req) = plan_compaction(&c, keep_last, target) else {
378 continue;
379 };
380 let kept = &c.messages[req.fold..];
381 let Some(first) = kept.first() else {
382 continue; };
384 assert!(
385 first.is_user(),
386 "keep_last {keep_last} target {target:?} → fold {} left {first:?} first",
387 req.fold
388 );
389 let calls: Vec<&str> = kept
391 .iter()
392 .flat_map(|m| match m {
393 Msg::Assistant { tool_calls, .. } => tool_calls.as_slice(),
394 _ => &[],
395 })
396 .map(|tc| tc.id.as_str())
397 .collect();
398 for m in kept {
399 if let Msg::Tool { id, .. } = m {
400 assert!(
401 calls.contains(&id.as_str()),
402 "keep_last {keep_last} target {target:?} → fold {} orphaned {id}",
403 req.fold
404 );
405 }
406 }
407 }
408 }
409 }
410
411 #[test]
412 fn apply_absorbs_the_summary_bumps_version_and_recounts() {
413 let mut c = ctx_with(10);
414 c.plan = Some(super::super::plan::Plan::create("goal", &[json!("a")], 32).unwrap());
415 c.load_skill("review", "h", 8).unwrap();
416 let before = c.est_tokens;
417 let req = plan_compaction(&c, 3, None).unwrap();
418 let out = apply_compaction(
419 &mut c,
420 &req,
421 &json!({"goals": ["finish"], "decisions": [], "open": ["q1"], "facts": ["n=7"]}),
422 )
423 .unwrap();
424 assert_eq!(out.folded, 7);
425 assert_eq!(out.version, 2);
426 assert_eq!(c.messages.len(), 3);
427 assert_eq!(c.summary.goals, vec!["finish".to_string()]);
428 assert_eq!(c.summary.covers_messages, 7);
429 assert!(c.est_tokens < before);
430 assert!(c.plan.is_some(), "plan kept verbatim");
431 assert_eq!(c.skills.len(), 1, "skill names kept");
432 assert!(c.dirty);
433 let req2 = plan_compaction(&ctx_with(10), 3, None).unwrap();
435 assert!(apply_compaction(&mut c, &req2, &json!({})).is_err());
436 let mut c2 = ctx_with(10);
438 let req = plan_compaction(&c2, 3, None).unwrap();
439 let out = apply_fallback(&mut c2, &req).unwrap();
440 assert_eq!(out.folded, 7);
441 assert!(
442 c2.summary
443 .narrative
444 .as_deref()
445 .unwrap()
446 .contains("message number 0")
447 );
448 let wire = c.to_wire();
450 assert!(
451 matches!(&wire[0], crate::wire::intel::Message::System(s) if s.starts_with("Summary of earlier"))
452 );
453 assert!(
454 matches!(&wire[1], crate::wire::intel::Message::System(s) if s.starts_with("Plan ("))
455 );
456 }
457}