Skip to main content

rig_memory/
compaction.rs

1//! Compacting a long conversation: a [`CompactionPolicy`] says when and where to cut and plans
2//! the summary, which [`apply_summary`] puts in place of the older messages; [`ClearToolOutputs`]
3//! frees context without a model call. The default prompts and limits follow the
4//! [pi coding agent](https://github.com/badlogic/pi-mono).
5//!
6//! ```
7//! use rig_memory::{CompactionPolicy, SummaryState};
8//!
9//! let policy = CompactionPolicy::default();
10//! let reason = rig_memory::CompactReason::Threshold;
11//! assert_eq!(policy.cut(&[], 0, &reason, None), None);
12//! assert_eq!(SummaryState::default().message(), None);
13//! ```
14
15use std::borrow::{Borrow, Cow};
16use std::collections::{BTreeMap, BTreeSet};
17use std::fmt::{self, Write as _};
18use std::sync::Arc;
19
20use rig_core::catalog::ModelSpec;
21use rig_core::completion::{
22    AssistantContent, CompletionRequest, CompletionResponse, FinishReason, Message,
23    UnsupportedOption,
24};
25use rig_core::conversation::{
26    Aside, AsideId, Author, Change, Conversation, Entry, MessageId, Spliced,
27};
28use rig_core::message::{ToolCall, ToolResult, ToolResultContent, UserContent};
29use serde::{Deserialize, Serialize};
30
31use crate::session::{LogRecord, as_of, history};
32use crate::{HeuristicTokenCounter, SessionError, Store, TokenCounter};
33
34/// A summary of a conversation's older messages and the values tracked
35/// across compactions (such as the files a coding agent read). The default
36/// is the state before the first compaction.
37#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct SummaryState {
39    /// The summary; empty before the first compaction.
40    pub summary: String,
41    /// Named sets of values, in the order they were first tracked.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub tracked: Vec<TrackedSet>,
44}
45
46/// One named set of a [`SummaryState`], sent after the summary inside a
47/// `<name>` tag.
48#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub struct TrackedSet {
50    /// The set's name, also its tag.
51    pub name: String,
52    /// Its values.
53    pub values: BTreeSet<String>,
54}
55
56/// A rule of [`SummaryState::track`]: the string argument `argument` of
57/// every call of the tool `tool` goes into the set `set`.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct TrackArgument {
60    /// The tool's name.
61    pub tool: Cow<'static, str>,
62    /// The argument's name.
63    pub argument: Cow<'static, str>,
64    /// The set's name.
65    pub set: Cow<'static, str>,
66}
67
68impl TrackArgument {
69    /// The rule tracking `argument` of `tool` in the set `set`.
70    pub const fn new(tool: &'static str, argument: &'static str, set: &'static str) -> Self {
71        Self {
72            tool: Cow::Borrowed(tool),
73            argument: Cow::Borrowed(argument),
74            set: Cow::Borrowed(set),
75        }
76    }
77
78    /// The argument it tracks of `call`, when `call` is of its tool and
79    /// that argument is a string.
80    pub fn value<'c>(&self, call: &'c ToolCall) -> Option<&'c str> {
81        let function = &call.function;
82        if self.tool != function.name.as_str() {
83            return None;
84        }
85        function.arguments.get(self.argument.as_ref())?.as_str()
86    }
87}
88
89impl SummaryState {
90    /// The text that stands for the summarized messages, `None` before the
91    /// first compaction. A value appears only in the last set that holds
92    /// it, so a later set (files changed) overrides an earlier one (files
93    /// read).
94    pub fn message(&self) -> Option<String> {
95        if self.summary.is_empty() {
96            return None;
97        }
98        let mut text = format!(
99            "The conversation before this point was compacted into this summary:\n\n\
100             <summary>\n{}\n</summary>",
101            self.summary.trim()
102        );
103        for (index, set) in self.tracked.iter().enumerate() {
104            let later = self.tracked.get(index + 1..).unwrap_or_default();
105            let values: Vec<&str> = set
106                .values
107                .iter()
108                .filter(|value| !later.iter().any(|later| later.values.contains(*value)))
109                .map(String::as_str)
110                .collect();
111            if !values.is_empty() {
112                let _ = write!(
113                    text,
114                    "\n\n<{name}>\n{}\n</{name}>",
115                    values.join("\n"),
116                    name = set.name
117                );
118            }
119        }
120        Some(text)
121    }
122
123    /// Adds the arguments `rules` name of the tool calls in `messages` to
124    /// the tracked sets, creating the rules' sets in rule order first.
125    pub fn track(&mut self, messages: &[impl Borrow<Message>], rules: &[TrackArgument]) {
126        for rule in rules {
127            if !self.tracked.iter().any(|set| set.name == rule.set) {
128                self.tracked.push(TrackedSet {
129                    name: rule.set.clone().into_owned(),
130                    values: BTreeSet::new(),
131                });
132            }
133        }
134        let calls = messages.iter().flat_map(|message| match message.borrow() {
135            Message::Assistant(reply) => reply.content.as_slice(),
136            _ => &[],
137        });
138        for item in calls {
139            let AssistantContent::ToolCall(call) = item else {
140                continue;
141            };
142            for rule in rules {
143                let set = self.tracked.iter_mut().find(|set| set.name == rule.set);
144                if let (Some(value), Some(set)) = (rule.value(call), set) {
145                    set.values.insert(value.to_owned());
146                }
147            }
148        }
149    }
150}
151
152/// Where a new compaction of `messages`, whose first `from` are summarized
153/// already, should end: the first message it keeps. Walking back from the
154/// newest message, it keeps at least `keep_tokens` and cuts before a reply
155/// or a user's own message, never between a tool call and its result.
156/// With `force`, a shorter conversation still has its older messages
157/// summarized, all but the newest reply or message. `None` when nothing
158/// new would be summarized.
159fn cut_at(
160    messages: &[Arc<Message>],
161    from: usize,
162    keep_tokens: usize,
163    force: bool,
164    counter: &impl TokenCounter,
165) -> Option<usize> {
166    let mut kept = 0;
167    let mut newest = None;
168    for (index, message) in messages.iter().enumerate().skip(from + 1).rev() {
169        kept += counter.count(message);
170        if !can_start_live(message) {
171            continue;
172        }
173        newest.get_or_insert(index);
174        if kept >= keep_tokens {
175            return Some(index);
176        }
177    }
178    newest.filter(|_| force)
179}
180
181/// Whether the messages a request sends as they are may start with
182/// `message`: a reply, or a user message that answers no tool call.
183fn can_start_live(message: &Message) -> bool {
184    match message {
185        Message::User { content } => !content
186            .iter()
187            .any(|item| matches!(item, UserContent::ToolResult(_))),
188        Message::Assistant(_) | Message::System { .. } => true,
189    }
190}
191
192/// Why a conversation is compacted.
193#[derive(Clone, Debug, PartialEq, Eq)]
194pub enum CompactReason {
195    /// The user asked, with what to focus on, if anything.
196    Asked {
197        /// What the summary should keep above all; may be empty.
198        focus: String,
199    },
200    /// The next request would leave less than the reserve of the model's
201    /// window free.
202    Threshold,
203    /// The model refused the request as too long.
204    Overflow,
205}
206
207/// How a conversation is compacted: what the summarizer is asked, the tool
208/// arguments a summary keeps track of, such as the files a coding agent's
209/// file tools read and changed, how much of the newest conversation stays
210/// as it is, and how old tool outputs are cleared first. Tokens are
211/// estimated by the default [`HeuristicTokenCounter`].
212///
213/// Its serde form is one flat table, the summarizer's `prompts` and
214/// `limits` beside the other fields; each field missing is the default's,
215/// and an unknown one is refused.
216///
217/// ```
218/// use rig_memory::CompactionPolicy;
219///
220/// let policy: CompactionPolicy =
221///     serde_json::from_value(serde_json::json!({"keep_recent": 1000, "limits": {"reserve": 1}}))?;
222/// assert_eq!(policy.keep_recent, 1000);
223/// assert_eq!(policy.summarizer.limits.reserve, 1);
224/// assert_eq!(policy.keep_asked, CompactionPolicy::default().keep_asked);
225/// # Ok::<(), serde_json::Error>(())
226/// ```
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228#[serde(from = "PolicyTable", into = "PolicyTable")]
229pub struct CompactionPolicy {
230    /// The summarizer; its reserve is how much of the model's window is
231    /// kept free.
232    pub summarizer: Summarizer,
233    /// The tool arguments tracked across compactions.
234    pub tracked: Vec<TrackArgument>,
235    /// Tokens of the newest messages an automatic compaction keeps as they
236    /// are, a quarter of the model's window at most: the work carries on,
237    /// so its recent part stays.
238    pub keep_recent: usize,
239    /// Tokens of the newest messages a compaction the user asked for keeps
240    /// as they are; the newest reply or message always stays.
241    pub keep_asked: usize,
242    /// How old tool outputs are cleared before a summary is asked for.
243    pub clearing: ClearToolOutputs,
244    /// What happens to the tool outputs a clearing near the window drops.
245    pub near_window: NearWindow,
246}
247
248impl Default for CompactionPolicy {
249    /// [`Summarizer::DEFAULT`], nothing tracked, the newest 20k tokens kept
250    /// by an automatic compaction and none beyond the newest reply or
251    /// message by an asked one, [`ClearToolOutputs::default`], and notes
252    /// on what clearing drops near the window.
253    fn default() -> Self {
254        Self {
255            summarizer: Summarizer::DEFAULT,
256            tracked: Vec::new(),
257            keep_recent: 20_000,
258            keep_asked: 0,
259            clearing: ClearToolOutputs::default(),
260            near_window: NearWindow::default(),
261        }
262    }
263}
264
265/// What a clearing near the model's window does with the tool outputs it
266/// drops; its serde form is `"summarize"` or `"clear"`.
267#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "lowercase")]
269pub enum NearWindow {
270    /// The model first writes notes on the outputs that cannot be had
271    /// again ([`SummaryPrompts::notes`]), which stay after them.
272    #[default]
273    Summarize,
274    /// They are cleared with no model call.
275    Clear,
276}
277
278/// The serde form of a [`CompactionPolicy`]: one flat table.
279#[derive(Serialize, Deserialize)]
280#[serde(default, deny_unknown_fields)]
281struct PolicyTable {
282    prompts: SummaryPrompts,
283    limits: SummaryLimits,
284    tracked: Vec<TrackArgument>,
285    keep_recent: usize,
286    keep_asked: usize,
287    clearing: ClearToolOutputs,
288    near_window: NearWindow,
289}
290
291impl Default for PolicyTable {
292    fn default() -> Self {
293        CompactionPolicy::default().into()
294    }
295}
296
297impl From<PolicyTable> for CompactionPolicy {
298    fn from(table: PolicyTable) -> Self {
299        Self {
300            summarizer: Summarizer {
301                prompts: table.prompts,
302                limits: table.limits,
303            },
304            tracked: table.tracked,
305            keep_recent: table.keep_recent,
306            keep_asked: table.keep_asked,
307            clearing: table.clearing,
308            near_window: table.near_window,
309        }
310    }
311}
312
313impl From<CompactionPolicy> for PolicyTable {
314    fn from(policy: CompactionPolicy) -> Self {
315        Self {
316            prompts: policy.summarizer.prompts,
317            limits: policy.summarizer.limits,
318            tracked: policy.tracked,
319            keep_recent: policy.keep_recent,
320            keep_asked: policy.keep_asked,
321            clearing: policy.clearing,
322            near_window: policy.near_window,
323        }
324    }
325}
326
327/// The estimated tokens of `messages`, by the default
328/// [`HeuristicTokenCounter`], as a policy estimates them.
329pub fn estimate_tokens(messages: &[impl Borrow<Message>]) -> u64 {
330    let counter = HeuristicTokenCounter::default();
331    messages
332        .iter()
333        .map(|message| counter.count(message.borrow()) as u64)
334        .fold(0, u64::saturating_add)
335}
336
337impl CompactionPolicy {
338    /// Whether a request of `tokens` leaves less than the summarizer's
339    /// reserve of `spec`'s window free. Never, when the window is not known.
340    pub fn over_threshold(&self, tokens: u64, spec: &ModelSpec) -> bool {
341        spec.context_window.is_some_and(|window| {
342            tokens > u64::from(window).saturating_sub(self.summarizer.limits.reserve)
343        })
344    }
345
346    /// Tokens of the newest messages a compaction for `reason` with `spec`
347    /// keeps as they are.
348    pub fn keep(&self, reason: &CompactReason, spec: Option<&ModelSpec>) -> usize {
349        match reason {
350            CompactReason::Asked { .. } => self.keep_asked,
351            CompactReason::Threshold | CompactReason::Overflow => spec
352                .and_then(|spec| spec.context_window)
353                .map_or(self.keep_recent, |window| {
354                    self.keep_recent
355                        .min(usize::try_from(window / 4).unwrap_or(usize::MAX))
356                }),
357        }
358    }
359
360    /// Where a compaction for `reason` should end: the index of the first
361    /// message it keeps, after the newest messages within
362    /// [`keep`](Self::keep) and never between a tool call and its result,
363    /// or `None` when nothing new would be summarized. `from` is how many
364    /// leading messages are summaries already ([`summarized_prefix`]). A
365    /// short conversation is still summarized when the user asked or the
366    /// model overflowed.
367    pub fn cut(
368        &self,
369        messages: &[Arc<Message>],
370        from: usize,
371        reason: &CompactReason,
372        spec: Option<&ModelSpec>,
373    ) -> Option<usize> {
374        cut_at(
375            messages,
376            from,
377            self.keep(reason, spec),
378            !matches!(reason, CompactReason::Threshold),
379            &HeuristicTokenCounter::default(),
380        )
381    }
382
383    /// The summary request for a compaction ending at `upto`, and the
384    /// [`SummaryState`] the new summary goes into (with the tracked
385    /// arguments of the newly summarized messages).
386    pub fn plan(
387        &self,
388        state: &SummaryState,
389        messages: &[Arc<Message>],
390        from: usize,
391        upto: usize,
392        spec: &ModelSpec,
393        reason: &CompactReason,
394    ) -> Result<(SummaryState, CompletionRequest), UnsupportedOption> {
395        let older = messages.get(from.min(upto)..upto).unwrap_or_default();
396        let focus = match reason {
397            CompactReason::Asked { focus } => focus.trim(),
398            _ => "",
399        };
400        let request = self
401            .summarizer
402            .request(older, &state.summary, focus, spec)?;
403        let mut next = state.clone();
404        next.track(older, &self.tracked);
405        Ok((next, request))
406    }
407}
408
409/// Who writes a compaction's summary: the author of its entry.
410pub fn summary_author() -> Author {
411    Author::plugin("compaction")
412}
413
414/// The summary a compaction put in, when `change` is one: the id of the
415/// entry by [`summary_author`] in a splice that took messages out.
416/// Clearing old tool outputs, by the same author, keeps every message and
417/// is none.
418pub fn summary_in(change: &Change) -> Option<MessageId> {
419    let Change::Splice { removed, with, .. } = change else {
420        return None;
421    };
422    let kept = |id: &MessageId| with.iter().any(|entry| entry.id == Some(*id));
423    if removed.iter().all(kept) {
424        return None;
425    }
426    let summary = summary_author();
427    with.iter()
428        .find(|entry| entry.author.as_ref() == Some(&summary))
429        .and_then(|entry| entry.id)
430}
431
432/// How many of the first entries of `conversation` are summaries (by
433/// [`summary_author`]): what a new compaction builds on from its
434/// [`SummaryState`] instead of reading it again. It is the `from` the
435/// policy's [`cut`](CompactionPolicy::cut) and
436/// [`plan`](CompactionPolicy::plan) are given.
437pub fn summarized_prefix(conversation: &Conversation) -> usize {
438    let author = summary_author();
439    let summaries = conversation.entries();
440    summaries
441        .take_while(|entry| entry.author == Some(&author))
442        .count()
443}
444
445/// A summary [`CompactionPolicy::plan_summary`] planned.
446#[derive(Clone, Debug)]
447pub struct SummaryPlan {
448    /// The first message the compaction keeps; `None` when it summarizes
449    /// every message. By id, so a splice made while the summary is written
450    /// does not move it.
451    pub keep_from: Option<MessageId>,
452    /// The state the summary goes into.
453    pub state: SummaryState,
454    /// The request for the summary.
455    pub request: CompletionRequest,
456}
457
458/// Where a compaction [`CompactionPolicy::cut_summary`] chose ends, with no
459/// request: for a summary asked at the end of the conversation itself
460/// ([`Summarizer::instruction`]).
461#[derive(Clone, Debug)]
462pub struct SummaryCut {
463    /// The first message kept, by id; `None` when it summarizes every
464    /// message.
465    pub keep_from: Option<MessageId>,
466    /// The state the summary goes into, with the tracked arguments of the
467    /// newly summarized messages.
468    pub state: SummaryState,
469}
470
471impl CompactionPolicy {
472    /// [`cut`](Self::cut) for `conversation`, with the state the summary
473    /// goes into. `None` when nothing new would be summarized.
474    pub fn cut_summary(
475        &self,
476        conversation: &Conversation,
477        state: &SummaryState,
478        reason: &CompactReason,
479        spec: &ModelSpec,
480    ) -> Option<SummaryCut> {
481        let messages = conversation.messages();
482        let from = summarized_prefix(conversation);
483        let upto = self.cut(messages, from, reason, Some(spec))?;
484        let mut next = state.clone();
485        next.track(
486            messages.get(from.min(upto)..upto).unwrap_or_default(),
487            &self.tracked,
488        );
489        Some(SummaryCut {
490            keep_from: conversation.id(upto),
491            state: next,
492        })
493    }
494
495    /// [`cut`](Self::cut) and [`plan`](Self::plan) in one step, for
496    /// `conversation`. Returns `None` when nothing new would be summarized.
497    ///
498    /// # Errors
499    ///
500    /// When `spec` does not take the summary request's options.
501    pub fn plan_summary(
502        &self,
503        conversation: &Conversation,
504        state: &SummaryState,
505        reason: &CompactReason,
506        spec: &ModelSpec,
507    ) -> Result<Option<SummaryPlan>, UnsupportedOption> {
508        let messages = conversation.messages();
509        let from = summarized_prefix(conversation);
510        let Some(upto) = self.cut(messages, from, reason, Some(spec)) else {
511            return Ok(None);
512        };
513        let (state, request) = self.plan(state, messages, from, upto, spec, reason)?;
514        Ok(Some(SummaryPlan {
515            keep_from: conversation.id(upto),
516            state,
517            request,
518        }))
519    }
520}
521
522/// Puts the summary of `state` in place of the messages of
523/// `conversation` before `keep_from` (every message with `None`), earlier
524/// summaries included: one user entry by [`summary_author`], a splice it
525/// makes, followed by an aside of kind [`COMPACTED`] that says what it
526/// replaced ([`Compacted`]), so [`undo_summary`] can put that back.
527/// `None`, changing nothing, when `keep_from` was taken out meanwhile or
528/// `state` has no summary.
529pub fn apply_summary(
530    conversation: &mut Conversation,
531    keep_from: Option<MessageId>,
532    state: &SummaryState,
533) -> Option<Spliced> {
534    let end = match keep_from {
535        None => conversation.messages().len(),
536        Some(kept) => conversation.index_of(kept)?,
537    };
538    let summary = Entry::new(Message::user(state.message()?)).by(summary_author());
539    let spliced = conversation.splice(..end, [summary], summary_author());
540    if let Some(&summary) = spliced.changed.first() {
541        let compacted = Compacted {
542            summary,
543            replaced: spliced.removed.clone(),
544            state: state.clone(),
545        };
546        let value = serde_json::to_value(compacted).unwrap_or_default();
547        let aside = Aside::new(COMPACTED, summary_author(), value).after(Some(summary));
548        conversation.add_aside(aside);
549    }
550    Some(spliced)
551}
552
553/// The kind of the aside [`apply_summary`] puts after each summary.
554pub const COMPACTED: &str = "rig.compaction";
555
556/// The summary state the next compaction of `conversation` builds on: the
557/// newest compaction's not undone ([`Compacted`]), or the state before any.
558pub fn summary_state(conversation: &Conversation) -> SummaryState {
559    Compacted::of(conversation)
560        .last()
561        .map(|(_, compacted)| compacted.state)
562        .unwrap_or_default()
563}
564
565/// What a compaction replaced, kept with the conversation in an aside of
566/// kind [`COMPACTED`] by [`summary_author`] after its summary: the newest
567/// one is what [`undo_summary`] puts back.
568#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
569pub struct Compacted {
570    /// The summary's entry.
571    pub summary: MessageId,
572    /// The entries it replaced, earlier summaries included, in order.
573    pub replaced: Vec<MessageId>,
574    /// The state it put in, which the next compaction builds on.
575    pub state: SummaryState,
576}
577
578impl Compacted {
579    /// The record of every compaction of `conversation` not undone, oldest
580    /// first, each with its aside.
581    pub fn of(conversation: &Conversation) -> impl Iterator<Item = (AsideId, Self)> + '_ {
582        let author = summary_author();
583        let records = conversation.asides();
584        let ours =
585            records.filter(move |(_, aside)| aside.kind == COMPACTED && aside.author == author);
586        ours.filter_map(|(id, aside)| Some((id, Self::deserialize(&aside.value).ok()?)))
587    }
588}
589
590/// What [`undo_summary`] did.
591#[derive(Clone, Debug, Default, PartialEq, Eq)]
592pub struct Uncompacted {
593    /// The entries put back in place of the summary, with their ids.
594    pub restored: Vec<MessageId>,
595    /// The summary state now: the compaction's before it, or the state
596    /// before any.
597    pub state: SummaryState,
598}
599
600/// Why [`undo_summary`] put nothing back.
601#[derive(Debug, thiserror::Error)]
602pub enum UndoError {
603    /// The conversation has no compaction left to undo.
604    #[error("there is no compaction to undo")]
605    Nothing,
606    /// The newest compaction's summary is no longer in the conversation.
607    #[error("its summary is no longer in the conversation")]
608    SummaryGone,
609    /// The session's log does not hold what it replaced, such as before
610    /// the compaction was saved.
611    #[error("the session's log does not hold what it replaced")]
612    NotInLog,
613    /// The session's log could not be read.
614    #[error(transparent)]
615    Load(#[from] SessionError),
616}
617
618/// Undoes the newest compaction of `conversation`, the conversation saved
619/// in the log `log` of `store`: the entries its summary replaced, read
620/// from that log as they were just before it, are put back in the
621/// summary's place with their ids ([`Conversation::restore`] by `by`), and
622/// its [`Compacted`] aside goes. Entries added after the summary stay
623/// after them. Undone one after another, compactions come back newest
624/// first, each with the summary it built on.
625///
626/// # Errors
627///
628/// [`UndoError`], changing nothing.
629pub fn undo_summary(
630    conversation: &mut Conversation,
631    store: &dyn Store,
632    log: &str,
633    by: Author,
634) -> Result<Uncompacted, UndoError> {
635    let (aside, compacted) = Compacted::of(conversation)
636        .last()
637        .ok_or(UndoError::Nothing)?;
638    let at = conversation
639        .index_of(compacted.summary)
640        .ok_or(UndoError::SummaryGone)?;
641    // The splice that made the summary, the first naming it: later ones
642    // put it back.
643    let made = history(store, log)?
644        .into_iter()
645        .find_map(|(stamp, record)| match record {
646            LogRecord::Change(recorded)
647                if summary_in(&recorded.change) == Some(compacted.summary) =>
648            {
649                Some(stamp.n)
650            }
651            _ => None,
652        });
653    let before = as_of(store, log, made.ok_or(UndoError::NotInLog)?)?;
654    let originals: Option<Vec<Entry>> = (compacted.replaced.iter())
655        .map(|id| before.entry(before.index_of(*id)?))
656        .collect();
657    let originals = originals.ok_or(UndoError::NotInLog)?;
658    let restored = conversation.restore(at..=at, originals, by).restored;
659    conversation.remove_aside(aside);
660    let state = Compacted::of(conversation).last().map(|(_, it)| it.state);
661    Ok(Uncompacted {
662        restored,
663        state: state.unwrap_or_default(),
664    })
665}
666
667/// What a [`Summarizer`] asks the model. Each prompt missing from its serde
668/// form is the default's.
669#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
670#[serde(default, deny_unknown_fields)]
671pub struct SummaryPrompts {
672    /// The summarizer's system prompt.
673    pub system: Cow<'static, str>,
674    /// The request for a first summary, after the conversation.
675    pub initial: Cow<'static, str>,
676    /// The request to fold the conversation into `<previous-summary>`.
677    pub update: Cow<'static, str>,
678    /// The summary's format, after any request.
679    pub format: Cow<'static, str>,
680    /// The request for a summary sent at the end of the conversation
681    /// itself, after the agent's own system prompt and tools, in place of
682    /// `system`, `initial` and `update` ([`Summarizer::instruction`]).
683    pub in_place: Cow<'static, str>,
684    /// The request for notes on the tool outputs a clearing drops, sent as
685    /// `in_place` is and opening with the same words ([`ClearPlan::apply`]).
686    pub notes: Cow<'static, str>,
687}
688
689impl SummaryPrompts {
690    /// A structured checkpoint of an agent's work: goal, constraints,
691    /// progress, decisions, next steps and critical context.
692    pub const DEFAULT: Self = Self {
693        system: Cow::Borrowed(
694            "You summarize a conversation between a user and a coding agent so that another \
695             model can continue the work from the summary alone. Read the conversation and \
696             write the summary in the exact format asked for.\n\n\
697             Do not continue the conversation. Do not answer questions in it. Output only the \
698             summary.",
699        ),
700        initial: Cow::Borrowed(
701            "The conversation above is to be summarized. Write a structured checkpoint of it \
702             that another model will use to continue the work.",
703        ),
704        update: Cow::Borrowed(
705            "The conversation above is the NEW part of a conversation whose earlier part is \
706             summarized in <previous-summary>. Update that summary with it:\n\
707             - keep everything in the previous summary that still holds;\n\
708             - add the new progress, decisions and context;\n\
709             - move items from \"In progress\" to \"Done\" when they were completed;\n\
710             - update \"Next steps\" to what is left;\n\
711             - drop what is no longer relevant.",
712        ),
713        format: Cow::Borrowed(
714            "\n\nUse exactly this format:\n\n\
715             ## Goal\n\
716             [What the user is trying to get done; several items if the session covers several \
717             tasks.]\n\n\
718             ## Constraints and preferences\n\
719             - [What the user asked for or ruled out, or \"(none)\"]\n\n\
720             ## Progress\n\
721             ### Done\n\
722             - [x] [Completed tasks and changes]\n\
723             ### In progress\n\
724             - [ ] [Current work]\n\
725             ### Blocked\n\
726             - [What prevents progress, if anything]\n\n\
727             ## Key decisions\n\
728             - **[Decision]**: [Why]\n\n\
729             ## Next steps\n\
730             1. [What should happen next, in order]\n\n\
731             ## Critical context\n\
732             - [Every specific value the work or the user may still need, copied character for \
733             character from the messages, tool outputs, notes on cleared outputs and the earlier \
734             summary: names, numbers, identifiers, paths, commands, errors. Never guess a value. Or \
735             \"(none)\".]\n\n\
736             Keep each section short, except Critical context. Keep exact file paths, function \
737             names, commands and error messages.",
738        ),
739        in_place: Cow::Borrowed(
740            "Stop here: do not continue the work, do not answer the user and do not call any \
741             tool. Instead write a structured checkpoint of the conversation above. It replaces \
742             that conversation, so another model must be able to continue the work from it and \
743             the messages kept after it alone. If the conversation opens with a summary of an \
744             earlier part, fold it in: keep everything in it that still holds, every value under \
745             \"Critical context\" word for word, add the new progress, decisions and context, \
746             move items from \"In progress\" to \"Done\" when they were completed, and update \
747             \"Next steps\".",
748        ),
749        notes: Cow::Borrowed(
750            "Stop here: do not continue the work, do not answer the user and do not call any \
751             tool. To fit the context window, the larger tool outputs above are about to be \
752             replaced by a placeholder. Your messages, the user's messages, the tool calls and \
753             earlier notes stay as they are. Write notes that keep what those outputs hold that \
754             the work or the user may still need and that calling a tool again would not give \
755             back exactly:\n\
756             - every specific value, copied character for character: names, numbers, codes, \
757             identifiers, paths, URLs, versions, dates, settings, commands and error messages;\n\
758             - results and findings the work depends on;\n\
759             - when an output updates an earlier value, the newest value and which one it \
760             replaces.\n\n\
761             Copy only what the outputs say: never guess, complete or correct a value. Leave out \
762             what a tool would show again unchanged, such as a file still on disk. Don't repeat \
763             earlier notes. Write short lists grouped by the call they come from, and output only \
764             the notes.",
765        ),
766    };
767}
768
769/// The sizes a [`Summarizer`] works within. Each size missing from its
770/// serde form is the default's.
771#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
772#[serde(default, deny_unknown_fields)]
773pub struct SummaryLimits {
774    /// Tokens left free below the model's window.
775    pub reserve: u64,
776    /// The longest summary asked for, in tokens.
777    pub summary_tokens: u64,
778    /// Characters of a tool's output or arguments the model sees.
779    pub snippet_chars: usize,
780    /// Characters of conversation sent when the model's window is unknown.
781    pub unknown_window_chars: usize,
782}
783
784impl SummaryLimits {
785    /// 16k tokens reserved, summaries up to 12k tokens, 2,000-character
786    /// snippets and 400k characters for an unknown window.
787    pub const DEFAULT: Self = Self {
788        reserve: 16_384,
789        summary_tokens: 12_000,
790        snippet_chars: 2_000,
791        unknown_window_chars: 400_000,
792    };
793}
794
795impl Default for SummaryLimits {
796    fn default() -> Self {
797        Self::DEFAULT
798    }
799}
800
801impl Default for SummaryPrompts {
802    fn default() -> Self {
803        Self::DEFAULT
804    }
805}
806
807/// Builds the request for a summary of a conversation's older messages and
808/// reads the summary from the model's answer. The conversation goes as
809/// text in one user message, so the model reads it rather than continues
810/// it.
811#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(default, deny_unknown_fields)]
813pub struct Summarizer {
814    /// What the model is asked.
815    pub prompts: SummaryPrompts,
816    /// Sizes.
817    pub limits: SummaryLimits,
818}
819
820impl Summarizer {
821    /// [`SummaryPrompts::DEFAULT`] within [`SummaryLimits::DEFAULT`].
822    pub const DEFAULT: Self = Self {
823        prompts: SummaryPrompts::DEFAULT,
824        limits: SummaryLimits::DEFAULT,
825    };
826}
827
828impl Default for Summarizer {
829    fn default() -> Self {
830        Self::DEFAULT
831    }
832}
833
834/// Why a summary cannot be used.
835#[derive(Clone, Debug, PartialEq, Eq)]
836#[non_exhaustive]
837pub enum SummaryError {
838    /// The summary hit the model's output limit, so it is incomplete.
839    Truncated,
840    /// The model called a tool instead of summarizing.
841    CalledTool,
842    /// The model returned no text.
843    Empty,
844}
845
846impl fmt::Display for SummaryError {
847    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
848        f.write_str(match self {
849            Self::Truncated => "the summary hit the model's output limit",
850            Self::CalledTool => "the model called a tool instead of summarizing",
851            Self::Empty => "the model returned no summary",
852        })
853    }
854}
855
856impl std::error::Error for SummaryError {}
857
858impl Summarizer {
859    /// The request to `spec` for a summary of `older`, merged into
860    /// `previous` when that is not empty. `focus` is what the summary
861    /// should keep above all; it may be empty.
862    pub fn request(
863        &self,
864        older: &[Arc<Message>],
865        previous: &str,
866        focus: &str,
867        spec: &ModelSpec,
868    ) -> Result<CompletionRequest, UnsupportedOption> {
869        let limits = &self.limits;
870        let budget = spec
871            .context_window
872            .map_or(limits.unknown_window_chars, |window| {
873                let tokens =
874                    u64::from(window).saturating_sub(limits.reserve + limits.summary_tokens);
875                // Three characters a token, to stay under the window with code.
876                usize::try_from(tokens.saturating_mul(3)).unwrap_or(usize::MAX)
877            });
878        let conversation = self.serialize(older);
879        let conversation = keep_end(&conversation, budget.saturating_sub(previous.len()));
880        let mut prompt = format!("<conversation>\n{conversation}\n</conversation>\n\n");
881        if previous.is_empty() {
882            prompt.push_str(&self.prompts.initial);
883        } else {
884            let _ = write!(
885                prompt,
886                "<previous-summary>\n{previous}\n</previous-summary>\n\n{}",
887                self.prompts.update
888            );
889        }
890        if !focus.is_empty() {
891            let _ = write!(prompt, "\n\nAdditional focus: {focus}");
892        }
893        prompt.push_str(&self.prompts.format);
894        let options = spec.default_options(None);
895        spec.validate(&options)?;
896        Ok(CompletionRequest::new(Message::user(prompt))
897            .preamble(self.prompts.system.clone().into_owned())
898            .options(options)
899            .max_tokens(self.max_tokens(spec)))
900    }
901
902    /// The longest summary asked of `spec`: the limit's, or the model's
903    /// output limit when that is lower.
904    pub fn max_tokens(&self, spec: &ModelSpec) -> u64 {
905        let most = self.limits.summary_tokens;
906        spec.max_output_tokens
907            .map_or(most, |model| most.min(u64::from(model)))
908    }
909
910    /// The message that asks for a summary at the end of the conversation
911    /// itself, the agent's own request with its system prompt and tools cut
912    /// before the first message kept, so the provider can read all of it
913    /// from its prompt cache: [`SummaryPrompts::in_place`], `focus` when it
914    /// is not empty, and the format.
915    pub fn instruction(&self, focus: &str) -> String {
916        let mut text = self.prompts.in_place.clone().into_owned();
917        if !focus.is_empty() {
918            let _ = write!(text, "\n\nAdditional focus: {focus}");
919        }
920        text.push_str(&self.prompts.format);
921        text
922    }
923
924    /// The summary in a finished answer, or why it cannot be used.
925    pub fn summary_text(response: &CompletionResponse) -> Result<String, SummaryError> {
926        if matches!(response.finish_reason(), Some(FinishReason::Length)) {
927            return Err(SummaryError::Truncated);
928        }
929        if response.tool_calls().next().is_some() {
930            return Err(SummaryError::CalledTool);
931        }
932        let text = response.text();
933        let text = text.trim();
934        if text.is_empty() {
935            return Err(SummaryError::Empty);
936        }
937        Ok(text.to_owned())
938    }
939
940    /// `messages` as text, one labelled block per part, with long tool
941    /// outputs and arguments cut.
942    fn serialize(&self, messages: &[Arc<Message>]) -> String {
943        let snippet = |text: &str| snippet(text, self.limits.snippet_chars);
944        let mut parts: Vec<String> = Vec::new();
945        for message in messages {
946            match &**message {
947                Message::System { content } => parts.push(format!("[System]: {content}")),
948                Message::User { content } => {
949                    for item in content {
950                        match item {
951                            UserContent::Text(text) => {
952                                parts.push(format!("[User]: {}", text.text));
953                            }
954                            UserContent::ToolResult(result) => {
955                                let output: Vec<String> = result
956                                    .content
957                                    .iter()
958                                    .map(|part| match part {
959                                        ToolResultContent::Text(text) => text.text.clone(),
960                                        ToolResultContent::Json { value } => value.to_string(),
961                                        ToolResultContent::Image(_) => "[image]".to_owned(),
962                                    })
963                                    .collect();
964                                let label = if result.is_error {
965                                    "Tool error"
966                                } else {
967                                    "Tool result"
968                                };
969                                parts.push(format!("[{label}]: {}", snippet(&output.join("\n"))));
970                            }
971                            _ => parts.push("[User]: [attachment]".to_owned()),
972                        }
973                    }
974                }
975                Message::Assistant(reply) => {
976                    for item in &reply.content {
977                        match item {
978                            AssistantContent::Text(text) => {
979                                parts.push(format!("[Assistant]: {}", text.text));
980                            }
981                            AssistantContent::Reasoning(reasoning)
982                                if !reasoning.text.is_empty() =>
983                            {
984                                parts.push(format!("[Assistant thinking]: {}", reasoning.text));
985                            }
986                            AssistantContent::ToolCall(call) => {
987                                let arguments = serde_json::to_string(&call.function.arguments);
988                                parts.push(format!(
989                                    "[Assistant tool call]: {}({})",
990                                    call.function.name.as_str(),
991                                    snippet(&arguments.unwrap_or_default())
992                                ));
993                            }
994                            _ => {}
995                        }
996                    }
997                }
998            }
999        }
1000        parts.join("\n\n")
1001    }
1002}
1003
1004/// The last `budget` bytes of `text`, cut at a character, with a note when
1005/// the start is left out.
1006fn keep_end(text: &str, budget: usize) -> String {
1007    if text.len() <= budget {
1008        return text.to_owned();
1009    }
1010    let start = text.ceil_char_boundary(text.len() - budget);
1011    let rest = text.get(start..).unwrap_or_default();
1012    format!("[… {start} earlier characters left out]\n{rest}")
1013}
1014
1015/// `text` cut to `chars` characters, saying how much was cut.
1016fn snippet(text: &str, chars: usize) -> String {
1017    match text.char_indices().nth(chars) {
1018        Some((end, _)) => format!(
1019            "{}\n[… {} more characters]",
1020            text.get(..end).unwrap_or_default(),
1021            text.len() - end
1022        ),
1023        None => text.to_owned(),
1024    }
1025}
1026
1027/// What [`ClearToolOutputs::clear`] took out.
1028#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1029pub struct Cleared {
1030    /// Tool results cleared.
1031    pub results: usize,
1032    /// Their estimated tokens.
1033    pub tokens: usize,
1034}
1035
1036/// Frees context by clearing the outputs of older tool calls: walking back
1037/// from the end, the outputs within the first `keep_tokens` stay, and every
1038/// older one is replaced by a placeholder. The last message is never
1039/// touched: it is what the model must answer. Error results stay, being
1040/// short and telling the model what went wrong, and so do outputs under
1041/// `keep_under` tokens, which free little. The calls themselves stay, so
1042/// the conversation keeps its shape. Each size missing from its serde form
1043/// is the default's.
1044#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1045#[serde(default, deny_unknown_fields)]
1046pub struct ClearToolOutputs {
1047    /// Tokens of the newest tool output kept.
1048    pub keep_tokens: usize,
1049    /// Tokens under which an output is never cleared.
1050    pub keep_under: usize,
1051}
1052
1053impl Default for ClearToolOutputs {
1054    /// The newest 20k tokens of output kept, and every output under 500.
1055    fn default() -> Self {
1056        Self {
1057            keep_tokens: 20_000,
1058            keep_under: 500,
1059        }
1060    }
1061}
1062
1063impl ClearToolOutputs {
1064    /// What a cleared output says.
1065    pub const PLACEHOLDER: &'static str =
1066        "[output cleared to fit the context window; run the tool again if needed]";
1067
1068    /// Keep the newest `keep_tokens` of tool output, counted by the default
1069    /// [`HeuristicTokenCounter`], and say [`Self::PLACEHOLDER`] instead of
1070    /// the rest but outputs under the default `keep_under`: the serde form
1071    /// that names only `keep_tokens`.
1072    ///
1073    /// ```
1074    /// use rig_memory::ClearToolOutputs;
1075    ///
1076    /// let config: ClearToolOutputs = serde_json::from_str(r#"{"keep_tokens": 8000}"#)?;
1077    /// assert_eq!(ClearToolOutputs::new(8000), config);
1078    /// # Ok::<(), serde_json::Error>(())
1079    /// ```
1080    pub fn new(keep_tokens: usize) -> Self {
1081        Self {
1082            keep_tokens,
1083            ..Self::default()
1084        }
1085    }
1086
1087    /// Clears older tool outputs of `messages` in place.
1088    pub fn clear(&self, messages: &mut [Arc<Message>]) -> Cleared {
1089        let (targets, cleared) = self.targets(messages);
1090        for (at, items) in targets {
1091            if let Some(message) = messages.get_mut(at) {
1092                clear_items(Arc::make_mut(message), &items);
1093            }
1094        }
1095        cleared
1096    }
1097
1098    /// Clears older tool outputs of `conversation`, as [`Self::clear`]
1099    /// does, rewriting the entries that change in place by `by`
1100    /// ([`Conversation::edit_each`]), so they keep their ids.
1101    pub fn clear_conversation(&self, conversation: &mut Conversation, by: Author) -> Cleared {
1102        self.plan(conversation).apply(conversation, None, by)
1103    }
1104
1105    /// Which tool outputs of `conversation` [`Self::clear_conversation`]
1106    /// would clear, by message id, so the plan still applies once messages
1107    /// arrive or a copy of the conversation is edited.
1108    pub fn plan(&self, conversation: &Conversation) -> ClearPlan {
1109        let (targets, cleared) = self.targets(conversation.messages());
1110        let through = targets
1111            .keys()
1112            .next_back()
1113            .and_then(|&at| conversation.id(at));
1114        let targets = targets.into_iter();
1115        let targets = targets.filter_map(|(at, items)| Some((conversation.id(at)?, items)));
1116        ClearPlan {
1117            cleared,
1118            through,
1119            targets: targets.collect(),
1120        }
1121    }
1122
1123    /// The tool results of `messages` to clear, as the indices of their
1124    /// content items by message, and what clearing them takes out.
1125    fn targets(&self, messages: &[impl Borrow<Message>]) -> (BTreeMap<usize, Vec<usize>>, Cleared) {
1126        let counter = HeuristicTokenCounter::default();
1127        let mut cleared = Cleared::default();
1128        let mut targets: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1129        let mut kept = 0;
1130        let Some((_, earlier)) = messages.split_last() else {
1131            return (targets, cleared);
1132        };
1133        for (at, message) in earlier.iter().enumerate().rev() {
1134            let Message::User { content } = message.borrow() else {
1135                continue;
1136            };
1137            for (item_at, item) in content.iter().enumerate().rev() {
1138                let tokens = counter.count_user(item);
1139                let UserContent::ToolResult(result) = item else {
1140                    continue;
1141                };
1142                if result.is_error || is_cleared(&result.content) || tokens < self.keep_under {
1143                    continue;
1144                }
1145                kept += tokens;
1146                if kept <= self.keep_tokens {
1147                    continue;
1148                }
1149                targets.entry(at).or_default().push(item_at);
1150                cleared.results += 1;
1151                cleared.tokens += tokens;
1152            }
1153        }
1154        (targets, cleared)
1155    }
1156}
1157
1158/// The tool outputs a clearing replaces ([`ClearToolOutputs::plan`]), by
1159/// message id.
1160#[derive(Clone, Debug, Default, PartialEq, Eq)]
1161pub struct ClearPlan {
1162    /// What applying it takes out, as planned.
1163    pub cleared: Cleared,
1164    /// The newest message holding an output it clears, after whose outputs
1165    /// notes go; `None` when it clears nothing.
1166    pub through: Option<MessageId>,
1167    targets: BTreeMap<MessageId, Vec<usize>>,
1168}
1169
1170impl ClearPlan {
1171    /// The tool results of `conversation` it clears.
1172    pub fn results<'c>(
1173        &self,
1174        conversation: &'c Conversation,
1175    ) -> impl Iterator<Item = &'c ToolResult> {
1176        conversation.entries().flat_map(move |entry| {
1177            let content = match entry.message {
1178                Message::User { content } => content.as_slice(),
1179                _ => &[],
1180            };
1181            let items = self.targets.get(&entry.id).into_iter().flatten();
1182            items.filter_map(|&at| match content.get(at) {
1183                Some(UserContent::ToolResult(result)) => Some(result),
1184                _ => None,
1185            })
1186        })
1187    }
1188
1189    /// Replaces the outputs it clears in `conversation` with the
1190    /// placeholder, with `notes`, if any, as a text part after
1191    /// [`through`](Self::through)'s, by one [`Conversation::edit_each`] by
1192    /// `by`. Returns what it planned to take out.
1193    pub fn apply(
1194        &self,
1195        conversation: &mut Conversation,
1196        notes: Option<&str>,
1197        by: Author,
1198    ) -> Cleared {
1199        conversation.edit_each(by, |entry| {
1200            let items = self.targets.get(&entry.id);
1201            let notes = notes.filter(|_| self.through == Some(entry.id));
1202            if items.is_none() && notes.is_none() {
1203                return None;
1204            }
1205            let items = items.map_or(&[][..], Vec::as_slice);
1206            let mut message = entry.message.clone();
1207            clear_items(&mut message, items);
1208            let noted = match (notes, &mut message) {
1209                (Some(notes), Message::User { content }) => {
1210                    content.push(UserContent::text(format!(
1211                        "<cleared-output-notes>\nNotes on the tool outputs above, written \
1212                         before they were cleared:\n{}\n</cleared-output-notes>",
1213                        notes.trim()
1214                    )));
1215                    true
1216                }
1217                _ => false,
1218            };
1219            (!items.is_empty() || noted).then_some(message)
1220        });
1221        self.cleared
1222    }
1223}
1224
1225/// Replaces the tool results at `items` of `message` with the placeholder.
1226fn clear_items(message: &mut Message, items: &[usize]) {
1227    let Message::User { content } = message else {
1228        return;
1229    };
1230    for (at, item) in content.iter_mut().enumerate() {
1231        if let UserContent::ToolResult(result) = item
1232            && items.contains(&at)
1233        {
1234            result.content = vec![ToolResultContent::text(ClearToolOutputs::PLACEHOLDER)];
1235        }
1236    }
1237}
1238
1239/// Whether `content` is a cleared output.
1240fn is_cleared(content: &[ToolResultContent]) -> bool {
1241    matches!(content, [only] if only.as_text() == Some(ClearToolOutputs::PLACEHOLDER))
1242}
1243
1244#[cfg(test)]
1245mod tests;