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, never splits an assistant tool-call from its
58/// tool results, and always leaves a `User` message first in the kept window.
59/// Returns `None` when there is nothing worth folding (fewer than
60/// `keep_last + 2` messages, or no user message to fold up to).
61pub 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 a target is given, fold more aggressively until the estimate of the
72    // kept tail is under it (but always keep at least 2 messages).
73    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    // One boundary correction, and it subsumes the tool-round rule: walk back
81    // until the first kept message is a `User`. Two things go wrong otherwise,
82    // and because the context is durable a single bad fold poisons every later
83    // turn and survives a restart:
84    //   * an `Assistant` first sends `messages[0].role == "assistant"` in the
85    //     anthropic dialect, which the API rejects — the summary block is no
86    //     shield, system messages hoist into the top-level `system` field
87    //     (`intel/anthropic.rs`), so the assistant really is first on the wire;
88    //   * a `Tool` first is a `tool_result` block whose `tool_use` was folded
89    //     away — a dangling id, the split the old tool-round rule guarded
90    //     against from the other end.
91    // Walking back only ever keeps *more*, so both the "at least 2 kept" floor
92    // and the target-token loop above stay satisfied. `get` (not an index) is
93    // load-bearing: `keep_last` is caller-supplied and may be 0, and folding
94    // everything is fine — the next turn appends its user message first.
95    while fold > 0 && ctx.messages.get(fold).is_some_and(|m| !m.is_user()) {
96        fold -= 1;
97    }
98    // No user message at or before the boundary: decline rather than fold to a
99    // shape the provider refuses. An oversized context is recoverable; a
100    // checkpointed context that cannot be sent is not.
101    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
178/// Fold the summarizer's verdict into the context: absorb the summary, drop
179/// the folded messages, bump the version, evict unreferenced skill bodies
180/// (names stay — the caller drops the bodies from its cache), recount.
181/// Refuses when the context changed since the plan (`version` drift).
182pub 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
229/// A degraded compaction for when the summarizer is unavailable: fold the
230/// older messages into a plain narrative built from their rendered lines
231/// (truncated). Never loses the plan or the skill names.
232pub 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        // 9 messages; keep_last 2 → fold 7 → messages[7] is the tool result, [6]
284        // its assistant call, [5] the user that opened the round → fold 5, the
285        // last boundary that leaves a user message first (see the walk-back).
286        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        // A target folds more aggressively (never below 2 kept).
304        let big = ctx_with(20);
305        let req = plan_compaction(&big, 10, Some(1)).unwrap();
306        assert_eq!(req.fold, 18);
307    }
308
309    /// A mixed transcript: `user, assistant, user, assistant(call), tool,
310    /// assistant, user, assistant(call), tool, tool, assistant`.
311    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    /// The kept window must open on a `User`. The anthropic dialect hoists
338    /// system messages (summary + plan included) into the top-level `system`
339    /// field, so an assistant left first by a fold really is `messages[0]` on
340    /// the wire and the API rejects it — and the context is durable, so that
341    /// fold poisons every later turn until someone edits the record by hand.
342    #[test]
343    fn fold_never_leaves_an_assistant_or_a_tool_result_first() {
344        let c = mixed_ctx();
345        // keep_last 1 → fold 10 (the trailing assistant); 9 and 8 are tool
346        // results, 7 their assistant call → the user at 6 is the boundary.
347        let req = plan_compaction(&c, 1, None).unwrap();
348        assert_eq!(req.fold, 6);
349        assert!(c.messages[req.fold].is_user());
350        // keep_last 5 → fold 6 already lands on the user; nothing to correct.
351        assert_eq!(plan_compaction(&c, 5, None).unwrap().fold, 6);
352        // keep_last 7 → fold 4 is a tool result → back past its call at 3 to
353        // the user at 2 (the old rule stopped at 3, an assistant).
354        let req = plan_compaction(&c, 7, None).unwrap();
355        assert_eq!(req.fold, 2);
356        assert!(c.messages[req.fold].is_user());
357        // An aggressive target folds forward first, then walks back the same way.
358        let req = plan_compaction(&c, 2, Some(1)).unwrap();
359        assert!(c.messages[req.fold].is_user());
360        // A transcript with no user message at all is refused rather than
361        // folded into a shape the provider will not accept.
362        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    /// Property-style: over every fold point the two callers can ask for, the
370    /// kept window opens on a `User` and carries no orphaned tool result.
371    #[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; // keep_last 0: everything folds, nothing to lead
383                };
384                assert!(
385                    first.is_user(),
386                    "keep_last {keep_last} target {target:?} → fold {} left {first:?} first",
387                    req.fold
388                );
389                // No kept tool result may reference a call that was folded away.
390                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        // Version drift is refused.
434        let req2 = plan_compaction(&ctx_with(10), 3, None).unwrap();
435        assert!(apply_compaction(&mut c, &req2, &json!({})).is_err());
436        // Fallback path.
437        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        // A wire slice carries the summary + plan first.
449        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}