use std::borrow::{Borrow, Cow};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{self, Write as _};
use std::sync::Arc;
use rig_core::catalog::ModelSpec;
use rig_core::completion::{
AssistantContent, CompletionRequest, CompletionResponse, FinishReason, Message,
UnsupportedOption,
};
use rig_core::conversation::{
Aside, AsideId, Author, Change, Conversation, Entry, MessageId, Spliced,
};
use rig_core::message::{ToolCall, ToolResult, ToolResultContent, UserContent};
use serde::{Deserialize, Serialize};
use crate::session::{LogRecord, as_of, history};
use crate::{HeuristicTokenCounter, SessionError, Store, TokenCounter};
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SummaryState {
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tracked: Vec<TrackedSet>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrackedSet {
pub name: String,
pub values: BTreeSet<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrackArgument {
pub tool: Cow<'static, str>,
pub argument: Cow<'static, str>,
pub set: Cow<'static, str>,
}
impl TrackArgument {
pub const fn new(tool: &'static str, argument: &'static str, set: &'static str) -> Self {
Self {
tool: Cow::Borrowed(tool),
argument: Cow::Borrowed(argument),
set: Cow::Borrowed(set),
}
}
pub fn value<'c>(&self, call: &'c ToolCall) -> Option<&'c str> {
let function = &call.function;
if self.tool != function.name.as_str() {
return None;
}
function.arguments.get(self.argument.as_ref())?.as_str()
}
}
impl SummaryState {
pub fn message(&self) -> Option<String> {
if self.summary.is_empty() {
return None;
}
let mut text = format!(
"The conversation before this point was compacted into this summary:\n\n\
<summary>\n{}\n</summary>",
self.summary.trim()
);
for (index, set) in self.tracked.iter().enumerate() {
let later = self.tracked.get(index + 1..).unwrap_or_default();
let values: Vec<&str> = set
.values
.iter()
.filter(|value| !later.iter().any(|later| later.values.contains(*value)))
.map(String::as_str)
.collect();
if !values.is_empty() {
let _ = write!(
text,
"\n\n<{name}>\n{}\n</{name}>",
values.join("\n"),
name = set.name
);
}
}
Some(text)
}
pub fn track(&mut self, messages: &[impl Borrow<Message>], rules: &[TrackArgument]) {
for rule in rules {
if !self.tracked.iter().any(|set| set.name == rule.set) {
self.tracked.push(TrackedSet {
name: rule.set.clone().into_owned(),
values: BTreeSet::new(),
});
}
}
let calls = messages.iter().flat_map(|message| match message.borrow() {
Message::Assistant(reply) => reply.content.as_slice(),
_ => &[],
});
for item in calls {
let AssistantContent::ToolCall(call) = item else {
continue;
};
for rule in rules {
let set = self.tracked.iter_mut().find(|set| set.name == rule.set);
if let (Some(value), Some(set)) = (rule.value(call), set) {
set.values.insert(value.to_owned());
}
}
}
}
}
fn cut_at(
messages: &[Arc<Message>],
from: usize,
keep_tokens: usize,
force: bool,
counter: &impl TokenCounter,
) -> Option<usize> {
let mut kept = 0;
let mut newest = None;
for (index, message) in messages.iter().enumerate().skip(from + 1).rev() {
kept += counter.count(message);
if !can_start_live(message) {
continue;
}
newest.get_or_insert(index);
if kept >= keep_tokens {
return Some(index);
}
}
newest.filter(|_| force)
}
fn can_start_live(message: &Message) -> bool {
match message {
Message::User { content } => !content
.iter()
.any(|item| matches!(item, UserContent::ToolResult(_))),
Message::Assistant(_) | Message::System { .. } => true,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CompactReason {
Asked {
focus: String,
},
Threshold,
Overflow,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "PolicyTable", into = "PolicyTable")]
pub struct CompactionPolicy {
pub summarizer: Summarizer,
pub tracked: Vec<TrackArgument>,
pub keep_recent: usize,
pub keep_asked: usize,
pub clearing: ClearToolOutputs,
pub near_window: NearWindow,
}
impl Default for CompactionPolicy {
fn default() -> Self {
Self {
summarizer: Summarizer::DEFAULT,
tracked: Vec::new(),
keep_recent: 20_000,
keep_asked: 0,
clearing: ClearToolOutputs::default(),
near_window: NearWindow::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NearWindow {
#[default]
Summarize,
Clear,
}
#[derive(Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct PolicyTable {
prompts: SummaryPrompts,
limits: SummaryLimits,
tracked: Vec<TrackArgument>,
keep_recent: usize,
keep_asked: usize,
clearing: ClearToolOutputs,
near_window: NearWindow,
}
impl Default for PolicyTable {
fn default() -> Self {
CompactionPolicy::default().into()
}
}
impl From<PolicyTable> for CompactionPolicy {
fn from(table: PolicyTable) -> Self {
Self {
summarizer: Summarizer {
prompts: table.prompts,
limits: table.limits,
},
tracked: table.tracked,
keep_recent: table.keep_recent,
keep_asked: table.keep_asked,
clearing: table.clearing,
near_window: table.near_window,
}
}
}
impl From<CompactionPolicy> for PolicyTable {
fn from(policy: CompactionPolicy) -> Self {
Self {
prompts: policy.summarizer.prompts,
limits: policy.summarizer.limits,
tracked: policy.tracked,
keep_recent: policy.keep_recent,
keep_asked: policy.keep_asked,
clearing: policy.clearing,
near_window: policy.near_window,
}
}
}
pub fn estimate_tokens(messages: &[impl Borrow<Message>]) -> u64 {
let counter = HeuristicTokenCounter::default();
messages
.iter()
.map(|message| counter.count(message.borrow()) as u64)
.fold(0, u64::saturating_add)
}
impl CompactionPolicy {
pub fn over_threshold(&self, tokens: u64, spec: &ModelSpec) -> bool {
spec.context_window.is_some_and(|window| {
tokens > u64::from(window).saturating_sub(self.summarizer.limits.reserve)
})
}
pub fn keep(&self, reason: &CompactReason, spec: Option<&ModelSpec>) -> usize {
match reason {
CompactReason::Asked { .. } => self.keep_asked,
CompactReason::Threshold | CompactReason::Overflow => spec
.and_then(|spec| spec.context_window)
.map_or(self.keep_recent, |window| {
self.keep_recent
.min(usize::try_from(window / 4).unwrap_or(usize::MAX))
}),
}
}
pub fn cut(
&self,
messages: &[Arc<Message>],
from: usize,
reason: &CompactReason,
spec: Option<&ModelSpec>,
) -> Option<usize> {
cut_at(
messages,
from,
self.keep(reason, spec),
!matches!(reason, CompactReason::Threshold),
&HeuristicTokenCounter::default(),
)
}
pub fn plan(
&self,
state: &SummaryState,
messages: &[Arc<Message>],
from: usize,
upto: usize,
spec: &ModelSpec,
reason: &CompactReason,
) -> Result<(SummaryState, CompletionRequest), UnsupportedOption> {
let older = messages.get(from.min(upto)..upto).unwrap_or_default();
let focus = match reason {
CompactReason::Asked { focus } => focus.trim(),
_ => "",
};
let request = self
.summarizer
.request(older, &state.summary, focus, spec)?;
let mut next = state.clone();
next.track(older, &self.tracked);
Ok((next, request))
}
}
pub fn summary_author() -> Author {
Author::plugin("compaction")
}
pub fn summary_in(change: &Change) -> Option<MessageId> {
let Change::Splice { removed, with, .. } = change else {
return None;
};
let kept = |id: &MessageId| with.iter().any(|entry| entry.id == Some(*id));
if removed.iter().all(kept) {
return None;
}
let summary = summary_author();
with.iter()
.find(|entry| entry.author.as_ref() == Some(&summary))
.and_then(|entry| entry.id)
}
pub fn summarized_prefix(conversation: &Conversation) -> usize {
let author = summary_author();
let summaries = conversation.entries();
summaries
.take_while(|entry| entry.author == Some(&author))
.count()
}
#[derive(Clone, Debug)]
pub struct SummaryPlan {
pub keep_from: Option<MessageId>,
pub state: SummaryState,
pub request: CompletionRequest,
}
#[derive(Clone, Debug)]
pub struct SummaryCut {
pub keep_from: Option<MessageId>,
pub state: SummaryState,
}
impl CompactionPolicy {
pub fn cut_summary(
&self,
conversation: &Conversation,
state: &SummaryState,
reason: &CompactReason,
spec: &ModelSpec,
) -> Option<SummaryCut> {
let messages = conversation.messages();
let from = summarized_prefix(conversation);
let upto = self.cut(messages, from, reason, Some(spec))?;
let mut next = state.clone();
next.track(
messages.get(from.min(upto)..upto).unwrap_or_default(),
&self.tracked,
);
Some(SummaryCut {
keep_from: conversation.id(upto),
state: next,
})
}
pub fn plan_summary(
&self,
conversation: &Conversation,
state: &SummaryState,
reason: &CompactReason,
spec: &ModelSpec,
) -> Result<Option<SummaryPlan>, UnsupportedOption> {
let messages = conversation.messages();
let from = summarized_prefix(conversation);
let Some(upto) = self.cut(messages, from, reason, Some(spec)) else {
return Ok(None);
};
let (state, request) = self.plan(state, messages, from, upto, spec, reason)?;
Ok(Some(SummaryPlan {
keep_from: conversation.id(upto),
state,
request,
}))
}
}
pub fn apply_summary(
conversation: &mut Conversation,
keep_from: Option<MessageId>,
state: &SummaryState,
) -> Option<Spliced> {
let end = match keep_from {
None => conversation.messages().len(),
Some(kept) => conversation.index_of(kept)?,
};
let summary = Entry::new(Message::user(state.message()?)).by(summary_author());
let spliced = conversation.splice(..end, [summary], summary_author());
if let Some(&summary) = spliced.changed.first() {
let compacted = Compacted {
summary,
replaced: spliced.removed.clone(),
state: state.clone(),
};
let value = serde_json::to_value(compacted).unwrap_or_default();
let aside = Aside::new(COMPACTED, summary_author(), value).after(Some(summary));
conversation.add_aside(aside);
}
Some(spliced)
}
pub const COMPACTED: &str = "rig.compaction";
pub fn summary_state(conversation: &Conversation) -> SummaryState {
Compacted::of(conversation)
.last()
.map(|(_, compacted)| compacted.state)
.unwrap_or_default()
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Compacted {
pub summary: MessageId,
pub replaced: Vec<MessageId>,
pub state: SummaryState,
}
impl Compacted {
pub fn of(conversation: &Conversation) -> impl Iterator<Item = (AsideId, Self)> + '_ {
let author = summary_author();
let records = conversation.asides();
let ours =
records.filter(move |(_, aside)| aside.kind == COMPACTED && aside.author == author);
ours.filter_map(|(id, aside)| Some((id, Self::deserialize(&aside.value).ok()?)))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Uncompacted {
pub restored: Vec<MessageId>,
pub state: SummaryState,
}
#[derive(Debug, thiserror::Error)]
pub enum UndoError {
#[error("there is no compaction to undo")]
Nothing,
#[error("its summary is no longer in the conversation")]
SummaryGone,
#[error("the session's log does not hold what it replaced")]
NotInLog,
#[error(transparent)]
Load(#[from] SessionError),
}
pub fn undo_summary(
conversation: &mut Conversation,
store: &dyn Store,
log: &str,
by: Author,
) -> Result<Uncompacted, UndoError> {
let (aside, compacted) = Compacted::of(conversation)
.last()
.ok_or(UndoError::Nothing)?;
let at = conversation
.index_of(compacted.summary)
.ok_or(UndoError::SummaryGone)?;
let made = history(store, log)?
.into_iter()
.find_map(|(stamp, record)| match record {
LogRecord::Change(recorded)
if summary_in(&recorded.change) == Some(compacted.summary) =>
{
Some(stamp.n)
}
_ => None,
});
let before = as_of(store, log, made.ok_or(UndoError::NotInLog)?)?;
let originals: Option<Vec<Entry>> = (compacted.replaced.iter())
.map(|id| before.entry(before.index_of(*id)?))
.collect();
let originals = originals.ok_or(UndoError::NotInLog)?;
let restored = conversation.restore(at..=at, originals, by).restored;
conversation.remove_aside(aside);
let state = Compacted::of(conversation).last().map(|(_, it)| it.state);
Ok(Uncompacted {
restored,
state: state.unwrap_or_default(),
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SummaryPrompts {
pub system: Cow<'static, str>,
pub initial: Cow<'static, str>,
pub update: Cow<'static, str>,
pub format: Cow<'static, str>,
pub in_place: Cow<'static, str>,
pub notes: Cow<'static, str>,
}
impl SummaryPrompts {
pub const DEFAULT: Self = Self {
system: Cow::Borrowed(
"You summarize a conversation between a user and a coding agent so that another \
model can continue the work from the summary alone. Read the conversation and \
write the summary in the exact format asked for.\n\n\
Do not continue the conversation. Do not answer questions in it. Output only the \
summary.",
),
initial: Cow::Borrowed(
"The conversation above is to be summarized. Write a structured checkpoint of it \
that another model will use to continue the work.",
),
update: Cow::Borrowed(
"The conversation above is the NEW part of a conversation whose earlier part is \
summarized in <previous-summary>. Update that summary with it:\n\
- keep everything in the previous summary that still holds;\n\
- add the new progress, decisions and context;\n\
- move items from \"In progress\" to \"Done\" when they were completed;\n\
- update \"Next steps\" to what is left;\n\
- drop what is no longer relevant.",
),
format: Cow::Borrowed(
"\n\nUse exactly this format:\n\n\
## Goal\n\
[What the user is trying to get done; several items if the session covers several \
tasks.]\n\n\
## Constraints and preferences\n\
- [What the user asked for or ruled out, or \"(none)\"]\n\n\
## Progress\n\
### Done\n\
- [x] [Completed tasks and changes]\n\
### In progress\n\
- [ ] [Current work]\n\
### Blocked\n\
- [What prevents progress, if anything]\n\n\
## Key decisions\n\
- **[Decision]**: [Why]\n\n\
## Next steps\n\
1. [What should happen next, in order]\n\n\
## Critical context\n\
- [Every specific value the work or the user may still need, copied character for \
character from the messages, tool outputs, notes on cleared outputs and the earlier \
summary: names, numbers, identifiers, paths, commands, errors. Never guess a value. Or \
\"(none)\".]\n\n\
Keep each section short, except Critical context. Keep exact file paths, function \
names, commands and error messages.",
),
in_place: Cow::Borrowed(
"Stop here: do not continue the work, do not answer the user and do not call any \
tool. Instead write a structured checkpoint of the conversation above. It replaces \
that conversation, so another model must be able to continue the work from it and \
the messages kept after it alone. If the conversation opens with a summary of an \
earlier part, fold it in: keep everything in it that still holds, every value under \
\"Critical context\" word for word, add the new progress, decisions and context, \
move items from \"In progress\" to \"Done\" when they were completed, and update \
\"Next steps\".",
),
notes: Cow::Borrowed(
"Stop here: do not continue the work, do not answer the user and do not call any \
tool. To fit the context window, the larger tool outputs above are about to be \
replaced by a placeholder. Your messages, the user's messages, the tool calls and \
earlier notes stay as they are. Write notes that keep what those outputs hold that \
the work or the user may still need and that calling a tool again would not give \
back exactly:\n\
- every specific value, copied character for character: names, numbers, codes, \
identifiers, paths, URLs, versions, dates, settings, commands and error messages;\n\
- results and findings the work depends on;\n\
- when an output updates an earlier value, the newest value and which one it \
replaces.\n\n\
Copy only what the outputs say: never guess, complete or correct a value. Leave out \
what a tool would show again unchanged, such as a file still on disk. Don't repeat \
earlier notes. Write short lists grouped by the call they come from, and output only \
the notes.",
),
};
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SummaryLimits {
pub reserve: u64,
pub summary_tokens: u64,
pub snippet_chars: usize,
pub unknown_window_chars: usize,
}
impl SummaryLimits {
pub const DEFAULT: Self = Self {
reserve: 16_384,
summary_tokens: 12_000,
snippet_chars: 2_000,
unknown_window_chars: 400_000,
};
}
impl Default for SummaryLimits {
fn default() -> Self {
Self::DEFAULT
}
}
impl Default for SummaryPrompts {
fn default() -> Self {
Self::DEFAULT
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Summarizer {
pub prompts: SummaryPrompts,
pub limits: SummaryLimits,
}
impl Summarizer {
pub const DEFAULT: Self = Self {
prompts: SummaryPrompts::DEFAULT,
limits: SummaryLimits::DEFAULT,
};
}
impl Default for Summarizer {
fn default() -> Self {
Self::DEFAULT
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SummaryError {
Truncated,
CalledTool,
Empty,
}
impl fmt::Display for SummaryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Truncated => "the summary hit the model's output limit",
Self::CalledTool => "the model called a tool instead of summarizing",
Self::Empty => "the model returned no summary",
})
}
}
impl std::error::Error for SummaryError {}
impl Summarizer {
pub fn request(
&self,
older: &[Arc<Message>],
previous: &str,
focus: &str,
spec: &ModelSpec,
) -> Result<CompletionRequest, UnsupportedOption> {
let limits = &self.limits;
let budget = spec
.context_window
.map_or(limits.unknown_window_chars, |window| {
let tokens =
u64::from(window).saturating_sub(limits.reserve + limits.summary_tokens);
usize::try_from(tokens.saturating_mul(3)).unwrap_or(usize::MAX)
});
let conversation = self.serialize(older);
let conversation = keep_end(&conversation, budget.saturating_sub(previous.len()));
let mut prompt = format!("<conversation>\n{conversation}\n</conversation>\n\n");
if previous.is_empty() {
prompt.push_str(&self.prompts.initial);
} else {
let _ = write!(
prompt,
"<previous-summary>\n{previous}\n</previous-summary>\n\n{}",
self.prompts.update
);
}
if !focus.is_empty() {
let _ = write!(prompt, "\n\nAdditional focus: {focus}");
}
prompt.push_str(&self.prompts.format);
let options = spec.default_options(None);
spec.validate(&options)?;
Ok(CompletionRequest::new(Message::user(prompt))
.preamble(self.prompts.system.clone().into_owned())
.options(options)
.max_tokens(self.max_tokens(spec)))
}
pub fn max_tokens(&self, spec: &ModelSpec) -> u64 {
let most = self.limits.summary_tokens;
spec.max_output_tokens
.map_or(most, |model| most.min(u64::from(model)))
}
pub fn instruction(&self, focus: &str) -> String {
let mut text = self.prompts.in_place.clone().into_owned();
if !focus.is_empty() {
let _ = write!(text, "\n\nAdditional focus: {focus}");
}
text.push_str(&self.prompts.format);
text
}
pub fn summary_text(response: &CompletionResponse) -> Result<String, SummaryError> {
if matches!(response.finish_reason(), Some(FinishReason::Length)) {
return Err(SummaryError::Truncated);
}
if response.tool_calls().next().is_some() {
return Err(SummaryError::CalledTool);
}
let text = response.text();
let text = text.trim();
if text.is_empty() {
return Err(SummaryError::Empty);
}
Ok(text.to_owned())
}
fn serialize(&self, messages: &[Arc<Message>]) -> String {
let snippet = |text: &str| snippet(text, self.limits.snippet_chars);
let mut parts: Vec<String> = Vec::new();
for message in messages {
match &**message {
Message::System { content } => parts.push(format!("[System]: {content}")),
Message::User { content } => {
for item in content {
match item {
UserContent::Text(text) => {
parts.push(format!("[User]: {}", text.text));
}
UserContent::ToolResult(result) => {
let output: Vec<String> = result
.content
.iter()
.map(|part| match part {
ToolResultContent::Text(text) => text.text.clone(),
ToolResultContent::Json { value } => value.to_string(),
ToolResultContent::Image(_) => "[image]".to_owned(),
})
.collect();
let label = if result.is_error {
"Tool error"
} else {
"Tool result"
};
parts.push(format!("[{label}]: {}", snippet(&output.join("\n"))));
}
_ => parts.push("[User]: [attachment]".to_owned()),
}
}
}
Message::Assistant(reply) => {
for item in &reply.content {
match item {
AssistantContent::Text(text) => {
parts.push(format!("[Assistant]: {}", text.text));
}
AssistantContent::Reasoning(reasoning)
if !reasoning.text.is_empty() =>
{
parts.push(format!("[Assistant thinking]: {}", reasoning.text));
}
AssistantContent::ToolCall(call) => {
let arguments = serde_json::to_string(&call.function.arguments);
parts.push(format!(
"[Assistant tool call]: {}({})",
call.function.name.as_str(),
snippet(&arguments.unwrap_or_default())
));
}
_ => {}
}
}
}
}
}
parts.join("\n\n")
}
}
fn keep_end(text: &str, budget: usize) -> String {
if text.len() <= budget {
return text.to_owned();
}
let start = text.ceil_char_boundary(text.len() - budget);
let rest = text.get(start..).unwrap_or_default();
format!("[… {start} earlier characters left out]\n{rest}")
}
fn snippet(text: &str, chars: usize) -> String {
match text.char_indices().nth(chars) {
Some((end, _)) => format!(
"{}\n[… {} more characters]",
text.get(..end).unwrap_or_default(),
text.len() - end
),
None => text.to_owned(),
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Cleared {
pub results: usize,
pub tokens: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ClearToolOutputs {
pub keep_tokens: usize,
pub keep_under: usize,
}
impl Default for ClearToolOutputs {
fn default() -> Self {
Self {
keep_tokens: 20_000,
keep_under: 500,
}
}
}
impl ClearToolOutputs {
pub const PLACEHOLDER: &'static str =
"[output cleared to fit the context window; run the tool again if needed]";
pub fn new(keep_tokens: usize) -> Self {
Self {
keep_tokens,
..Self::default()
}
}
pub fn clear(&self, messages: &mut [Arc<Message>]) -> Cleared {
let (targets, cleared) = self.targets(messages);
for (at, items) in targets {
if let Some(message) = messages.get_mut(at) {
clear_items(Arc::make_mut(message), &items);
}
}
cleared
}
pub fn clear_conversation(&self, conversation: &mut Conversation, by: Author) -> Cleared {
self.plan(conversation).apply(conversation, None, by)
}
pub fn plan(&self, conversation: &Conversation) -> ClearPlan {
let (targets, cleared) = self.targets(conversation.messages());
let through = targets
.keys()
.next_back()
.and_then(|&at| conversation.id(at));
let targets = targets.into_iter();
let targets = targets.filter_map(|(at, items)| Some((conversation.id(at)?, items)));
ClearPlan {
cleared,
through,
targets: targets.collect(),
}
}
fn targets(&self, messages: &[impl Borrow<Message>]) -> (BTreeMap<usize, Vec<usize>>, Cleared) {
let counter = HeuristicTokenCounter::default();
let mut cleared = Cleared::default();
let mut targets: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
let mut kept = 0;
let Some((_, earlier)) = messages.split_last() else {
return (targets, cleared);
};
for (at, message) in earlier.iter().enumerate().rev() {
let Message::User { content } = message.borrow() else {
continue;
};
for (item_at, item) in content.iter().enumerate().rev() {
let tokens = counter.count_user(item);
let UserContent::ToolResult(result) = item else {
continue;
};
if result.is_error || is_cleared(&result.content) || tokens < self.keep_under {
continue;
}
kept += tokens;
if kept <= self.keep_tokens {
continue;
}
targets.entry(at).or_default().push(item_at);
cleared.results += 1;
cleared.tokens += tokens;
}
}
(targets, cleared)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ClearPlan {
pub cleared: Cleared,
pub through: Option<MessageId>,
targets: BTreeMap<MessageId, Vec<usize>>,
}
impl ClearPlan {
pub fn results<'c>(
&self,
conversation: &'c Conversation,
) -> impl Iterator<Item = &'c ToolResult> {
conversation.entries().flat_map(move |entry| {
let content = match entry.message {
Message::User { content } => content.as_slice(),
_ => &[],
};
let items = self.targets.get(&entry.id).into_iter().flatten();
items.filter_map(|&at| match content.get(at) {
Some(UserContent::ToolResult(result)) => Some(result),
_ => None,
})
})
}
pub fn apply(
&self,
conversation: &mut Conversation,
notes: Option<&str>,
by: Author,
) -> Cleared {
conversation.edit_each(by, |entry| {
let items = self.targets.get(&entry.id);
let notes = notes.filter(|_| self.through == Some(entry.id));
if items.is_none() && notes.is_none() {
return None;
}
let items = items.map_or(&[][..], Vec::as_slice);
let mut message = entry.message.clone();
clear_items(&mut message, items);
let noted = match (notes, &mut message) {
(Some(notes), Message::User { content }) => {
content.push(UserContent::text(format!(
"<cleared-output-notes>\nNotes on the tool outputs above, written \
before they were cleared:\n{}\n</cleared-output-notes>",
notes.trim()
)));
true
}
_ => false,
};
(!items.is_empty() || noted).then_some(message)
});
self.cleared
}
}
fn clear_items(message: &mut Message, items: &[usize]) {
let Message::User { content } = message else {
return;
};
for (at, item) in content.iter_mut().enumerate() {
if let UserContent::ToolResult(result) = item
&& items.contains(&at)
{
result.content = vec![ToolResultContent::text(ClearToolOutputs::PLACEHOLDER)];
}
}
}
fn is_cleared(content: &[ToolResultContent]) -> bool {
matches!(content, [only] if only.as_text() == Some(ClearToolOutputs::PLACEHOLDER))
}
#[cfg(test)]
mod tests;