1use 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct SummaryState {
39 pub summary: String,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub tracked: Vec<TrackedSet>,
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub struct TrackedSet {
50 pub name: String,
52 pub values: BTreeSet<String>,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct TrackArgument {
60 pub tool: Cow<'static, str>,
62 pub argument: Cow<'static, str>,
64 pub set: Cow<'static, str>,
66}
67
68impl TrackArgument {
69 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 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 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 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
152fn 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
181fn 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#[derive(Clone, Debug, PartialEq, Eq)]
194pub enum CompactReason {
195 Asked {
197 focus: String,
199 },
200 Threshold,
203 Overflow,
205}
206
207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228#[serde(from = "PolicyTable", into = "PolicyTable")]
229pub struct CompactionPolicy {
230 pub summarizer: Summarizer,
233 pub tracked: Vec<TrackArgument>,
235 pub keep_recent: usize,
239 pub keep_asked: usize,
242 pub clearing: ClearToolOutputs,
244 pub near_window: NearWindow,
246}
247
248impl Default for CompactionPolicy {
249 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "lowercase")]
269pub enum NearWindow {
270 #[default]
273 Summarize,
274 Clear,
276}
277
278#[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
327pub 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 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 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 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 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
409pub fn summary_author() -> Author {
411 Author::plugin("compaction")
412}
413
414pub 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
432pub 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#[derive(Clone, Debug)]
447pub struct SummaryPlan {
448 pub keep_from: Option<MessageId>,
452 pub state: SummaryState,
454 pub request: CompletionRequest,
456}
457
458#[derive(Clone, Debug)]
462pub struct SummaryCut {
463 pub keep_from: Option<MessageId>,
466 pub state: SummaryState,
469}
470
471impl CompactionPolicy {
472 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 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
522pub 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
553pub const COMPACTED: &str = "rig.compaction";
555
556pub fn summary_state(conversation: &Conversation) -> SummaryState {
559 Compacted::of(conversation)
560 .last()
561 .map(|(_, compacted)| compacted.state)
562 .unwrap_or_default()
563}
564
565#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
569pub struct Compacted {
570 pub summary: MessageId,
572 pub replaced: Vec<MessageId>,
574 pub state: SummaryState,
576}
577
578impl Compacted {
579 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
592pub struct Uncompacted {
593 pub restored: Vec<MessageId>,
595 pub state: SummaryState,
598}
599
600#[derive(Debug, thiserror::Error)]
602pub enum UndoError {
603 #[error("there is no compaction to undo")]
605 Nothing,
606 #[error("its summary is no longer in the conversation")]
608 SummaryGone,
609 #[error("the session's log does not hold what it replaced")]
612 NotInLog,
613 #[error(transparent)]
615 Load(#[from] SessionError),
616}
617
618pub 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
670#[serde(default, deny_unknown_fields)]
671pub struct SummaryPrompts {
672 pub system: Cow<'static, str>,
674 pub initial: Cow<'static, str>,
676 pub update: Cow<'static, str>,
678 pub format: Cow<'static, str>,
680 pub in_place: Cow<'static, str>,
684 pub notes: Cow<'static, str>,
687}
688
689impl SummaryPrompts {
690 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
772#[serde(default, deny_unknown_fields)]
773pub struct SummaryLimits {
774 pub reserve: u64,
776 pub summary_tokens: u64,
778 pub snippet_chars: usize,
780 pub unknown_window_chars: usize,
782}
783
784impl SummaryLimits {
785 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(default, deny_unknown_fields)]
813pub struct Summarizer {
814 pub prompts: SummaryPrompts,
816 pub limits: SummaryLimits,
818}
819
820impl Summarizer {
821 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#[derive(Clone, Debug, PartialEq, Eq)]
836#[non_exhaustive]
837pub enum SummaryError {
838 Truncated,
840 CalledTool,
842 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 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 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 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 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 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 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
1004fn 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
1015fn 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1029pub struct Cleared {
1030 pub results: usize,
1032 pub tokens: usize,
1034}
1035
1036#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1045#[serde(default, deny_unknown_fields)]
1046pub struct ClearToolOutputs {
1047 pub keep_tokens: usize,
1049 pub keep_under: usize,
1051}
1052
1053impl Default for ClearToolOutputs {
1054 fn default() -> Self {
1056 Self {
1057 keep_tokens: 20_000,
1058 keep_under: 500,
1059 }
1060 }
1061}
1062
1063impl ClearToolOutputs {
1064 pub const PLACEHOLDER: &'static str =
1066 "[output cleared to fit the context window; run the tool again if needed]";
1067
1068 pub fn new(keep_tokens: usize) -> Self {
1081 Self {
1082 keep_tokens,
1083 ..Self::default()
1084 }
1085 }
1086
1087 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 pub fn clear_conversation(&self, conversation: &mut Conversation, by: Author) -> Cleared {
1102 self.plan(conversation).apply(conversation, None, by)
1103 }
1104
1105 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
1161pub struct ClearPlan {
1162 pub cleared: Cleared,
1164 pub through: Option<MessageId>,
1167 targets: BTreeMap<MessageId, Vec<usize>>,
1168}
1169
1170impl ClearPlan {
1171 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 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
1225fn 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
1239fn is_cleared(content: &[ToolResultContent]) -> bool {
1241 matches!(content, [only] if only.as_text() == Some(ClearToolOutputs::PLACEHOLDER))
1242}
1243
1244#[cfg(test)]
1245mod tests;