Skip to main content

agentd/context/
compact.rs

1// SPDX-License-Identifier: Apache-2.0
2//! **Compaction** (RFC 0026 §5.2): 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 (apply refuses drift).
31    pub version: u64,
32}
33
34/// The summarizer's output schema (RFC 0026 §5.1 summary block).
35pub 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
56/// Decide what to fold. `keep_last` messages stay verbatim; a fold always
57/// ends before the kept window and never splits an assistant tool-call from
58/// its tool results. Returns `None` when there is nothing worth folding
59/// (fewer than `keep_last + 2` messages).
60pub 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 a target is given, fold more aggressively until the estimate of the
71    // kept tail is under it (but always keep at least 2 messages).
72    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    // Do not split a tool round: if the first kept message is a tool result,
80    // move the boundary back to its assistant call.
81    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
161/// Fold the summarizer's verdict into the context: absorb the summary, drop
162/// the folded messages, bump the version, evict unreferenced skill bodies
163/// (names stay — the caller drops the bodies from its cache), recount.
164/// Refuses when the context changed since the plan (`version` drift).
165pub 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
212/// A degraded compaction for when the summarizer is unavailable: fold the
213/// older messages into a plain narrative built from their rendered lines
214/// (truncated). Never loses the plan or the skill names.
215pub 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        // 9 messages; keep_last 2 → fold 7 → messages[7] is the tool result → move back to 6.
267        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        // A target folds more aggressively (never below 2 kept).
279        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        // Version drift is refused.
307        let req2 = plan_compaction(&ctx_with(10), 3, None).unwrap();
308        assert!(apply_compaction(&mut c, &req2, &json!({})).is_err());
309        // Fallback path.
310        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        // A wire slice carries the summary + plan first.
322        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}