Skip to main content

agentd/context/
compact.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Compaction**: when a context's token estimate crosses
3//! `context.compact_at × model_window` (or `context.compact` is called), the
4//! older messages are summarized by a structured `think` into the summary
5//! block, the last `keep_last` messages stay verbatim, the plan stays
6//! verbatim, skill bodies not referenced in the kept window are evicted (the
7//! names stay), the version bumps and the record is checkpointed.
8//!
9//! The runtime never calls the model itself, so compaction is two halves: a
10//! pure **plan** ([`plan_compaction`] — which messages to fold + the prompt +
11//! the output schema for the summarizer) and a pure **apply**
12//! ([`apply_compaction`] — fold the summarizer's verdict into the context).
13//! The turn worker runs the `think` in between.
14
15use super::{ContextState, Msg, Summary};
16use crate::state::now_ms;
17use serde_json::{Value, json};
18
19/// A prepared compaction: what to summarize and how.
20#[derive(Debug, Clone)]
21pub struct CompactionRequest {
22    /// The number of leading messages that will be folded.
23    pub fold: usize,
24    /// The summarizer prompt (system).
25    pub system: String,
26    /// The summarizer input (user).
27    pub input: String,
28    /// The summarizer's output schema.
29    pub output_schema: Value,
30    /// The context version this plan was made against. The model call happens
31    /// between planning and applying, so [`apply_compaction`] refuses if the
32    /// context has moved on in the meantime — folding by a stale message count
33    /// would drop messages the plan never summarized.
34    pub version: u64,
35}
36
37/// The summarizer's output schema: the fields of the summary block, all
38/// required, with no additional properties so a model cannot smuggle unread
39/// keys into a record that is kept forever.
40pub 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
61/// Decide what to fold. `keep_last` messages stay verbatim; a fold always
62/// ends before the kept window, never splits an assistant tool-call from its
63/// tool results, and always leaves a `User` message first in the kept window.
64/// Returns `None` when there is nothing worth folding (fewer than
65/// `keep_last + 2` messages, or no user message to fold up to).
66pub 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 a target is given, fold more aggressively until the estimate of the
77    // kept tail is under it (but always keep at least 2 messages).
78    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    // One boundary correction, and it subsumes the tool-round rule: walk back
86    // until the first kept message is a `User`. Two things go wrong otherwise,
87    // and because the context is durable a single bad fold poisons every later
88    // turn and survives a restart:
89    //   * an `Assistant` first sends `messages[0].role == "assistant"` in the
90    //     anthropic dialect, which the API rejects — the summary block is no
91    //     shield, system messages hoist into the top-level `system` field
92    //     (`intel/anthropic.rs`), so the assistant really is first on the wire;
93    //   * a `Tool` first is a `tool_result` block whose `tool_use` was folded
94    //     away — a dangling id, which is the same tool round split from the
95    //     other end.
96    // Walking back only ever keeps *more*, so both the "at least 2 kept" floor
97    // and the target-token loop above stay satisfied. `get` (not an index) is
98    // load-bearing: `keep_last` is caller-supplied and may be 0, and folding
99    // everything is fine — the next turn appends its user message first.
100    while fold > 0 && ctx.messages.get(fold).is_some_and(|m| !m.is_user()) {
101        fold -= 1;
102    }
103    // No user message at or before the boundary: decline rather than fold to a
104    // shape the provider refuses. An oversized context is recoverable; a
105    // checkpointed context that cannot be sent is not.
106    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
183/// Fold the summarizer's verdict into the context: absorb the summary, drop
184/// the folded messages, bump the version, evict unreferenced skill bodies
185/// (names stay — the caller drops the bodies from its cache), recount.
186/// Refuses when the context's `version` has moved since the plan was made, or
187/// when the plan's fold exceeds the messages actually present.
188pub 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
235/// A degraded compaction for when the summarizer is unavailable: fold the
236/// older messages into a plain narrative built from their rendered lines
237/// (truncated). Never loses the plan or the skill names.
238pub 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        // 9 messages; keep_last 2 → fold 7 → messages[7] is the tool result, [6]
290        // its assistant call, [5] the user that opened the round → fold 5, the
291        // last boundary that leaves a user message first (see the walk-back).
292        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        // A target folds more aggressively (never below 2 kept).
310        let big = ctx_with(20);
311        let req = plan_compaction(&big, 10, Some(1)).unwrap();
312        assert_eq!(req.fold, 18);
313    }
314
315    /// A mixed transcript: `user, assistant, user, assistant(call), tool,
316    /// assistant, user, assistant(call), tool, tool, assistant`.
317    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    /// The kept window must open on a `User`. The anthropic dialect hoists
344    /// system messages (summary + plan included) into the top-level `system`
345    /// field, so an assistant left first by a fold really is `messages[0]` on
346    /// the wire and the API rejects it — and the context is durable, so that
347    /// fold poisons every later turn until someone edits the record by hand.
348    #[test]
349    fn fold_never_leaves_an_assistant_or_a_tool_result_first() {
350        let c = mixed_ctx();
351        // keep_last 1 → fold 10 (the trailing assistant); 9 and 8 are tool
352        // results, 7 their assistant call → the user at 6 is the boundary.
353        let req = plan_compaction(&c, 1, None).unwrap();
354        assert_eq!(req.fold, 6);
355        assert!(c.messages[req.fold].is_user());
356        // keep_last 5 → fold 6 already lands on the user; nothing to correct.
357        assert_eq!(plan_compaction(&c, 5, None).unwrap().fold, 6);
358        // keep_last 7 → fold 4 is a tool result → back past its call at 3 to
359        // the user at 2; stopping at 3 would leave an assistant first.
360        let req = plan_compaction(&c, 7, None).unwrap();
361        assert_eq!(req.fold, 2);
362        assert!(c.messages[req.fold].is_user());
363        // An aggressive target folds forward first, then walks back the same way.
364        let req = plan_compaction(&c, 2, Some(1)).unwrap();
365        assert!(c.messages[req.fold].is_user());
366        // A transcript with no user message at all is refused rather than
367        // folded into a shape the provider will not accept.
368        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    /// Property-style: over every fold point the two callers can ask for, the
376    /// kept window opens on a `User` and carries no orphaned tool result.
377    #[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; // keep_last 0: everything folds, nothing to lead
389                };
390                assert!(
391                    first.is_user(),
392                    "keep_last {keep_last} target {target:?} → fold {} left {first:?} first",
393                    req.fold
394                );
395                // No kept tool result may reference a call that was folded away.
396                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        // A plan made against a different context version is refused.
440        let req2 = plan_compaction(&ctx_with(10), 3, None).unwrap();
441        assert!(apply_compaction(&mut c, &req2, &json!({})).is_err());
442        // Fallback path.
443        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        // A wire slice carries the summary + plan first.
455        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}