Skip to main content

ferrin_message/
prune.rs

1//! Message pruning: drop reasoning, tool calls and empty messages to control
2//! context size.
3//!
4//! Derived from the `pruneMessages` function of the Vercel AI SDK (Apache-2.0,
5//! Copyright 2023 Vercel, Inc.), translated from TypeScript to Rust and
6//! modified; see `NOTICE`. Intended for `prepare_step` callbacks.
7
8use std::collections::HashMap;
9use std::collections::HashSet;
10
11use ferrin_spec::ApprovalId;
12use ferrin_spec::ToolCallId;
13use ferrin_spec::ToolName;
14
15use crate::message::AssistantContent;
16use crate::message::Message;
17use crate::part::AssistantPart;
18use crate::part::ToolPart;
19
20/// Which reasoning parts to remove.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
22#[non_exhaustive]
23pub enum ReasoningPrune {
24    /// Keep reasoning.
25    #[default]
26    None,
27    /// Remove reasoning from every assistant message.
28    All,
29    /// Remove reasoning from every message except the last one.
30    BeforeLastMessage,
31}
32
33/// Which messages a tool-call rule applies to.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum PruneScope {
37    /// Every message.
38    All,
39    /// Every message except the trailing `n`; tool calls referenced by those
40    /// trailing messages are kept everywhere. `n == 0` keeps all tool parts,
41    /// matching the reference SDK's `slice(-0)` behavior.
42    BeforeLastMessages(usize),
43}
44
45impl PruneScope {
46    /// Every message except the last one.
47    #[must_use]
48    pub const fn before_last_message() -> Self {
49        Self::BeforeLastMessages(1)
50    }
51}
52
53/// A rule removing tool calls, results, approval requests and responses.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ToolCallPrune {
56    /// Messages the rule applies to.
57    pub scope: PruneScope,
58    /// Restrict the rule to these tools; `None` prunes every tool.
59    pub tools: Option<Vec<ToolName>>,
60}
61
62/// What to do with messages left without content.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
64#[non_exhaustive]
65pub enum EmptyMessages {
66    /// Keep them.
67    Keep,
68    /// Remove them (also removes messages that were empty before pruning).
69    #[default]
70    Remove,
71}
72
73/// Options for [`prune`].
74#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct PruneOptions {
76    /// Reasoning removal.
77    pub reasoning: ReasoningPrune,
78    /// Tool-call rules, applied in order.
79    pub tool_calls: Vec<ToolCallPrune>,
80    /// Empty message handling.
81    pub empty_messages: EmptyMessages,
82}
83
84impl PruneOptions {
85    /// Default options: keep everything except empty messages.
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Sets reasoning removal.
92    #[must_use]
93    pub fn reasoning(mut self, reasoning: ReasoningPrune) -> Self {
94        self.reasoning = reasoning;
95        self
96    }
97
98    /// Adds a rule pruning every tool within `scope`.
99    #[must_use]
100    pub fn tool_calls(mut self, scope: PruneScope) -> Self {
101        self.tool_calls.push(ToolCallPrune { scope, tools: None });
102        self
103    }
104
105    /// Adds a rule pruning only `tools` within `scope`.
106    #[must_use]
107    pub fn tool_calls_for(
108        mut self,
109        scope: PruneScope,
110        tools: impl IntoIterator<Item = impl Into<ToolName>>,
111    ) -> Self {
112        self.tool_calls.push(ToolCallPrune {
113            scope,
114            tools: Some(tools.into_iter().map(Into::into).collect()),
115        });
116        self
117    }
118
119    /// Keeps messages that end up empty.
120    #[must_use]
121    pub fn keep_empty_messages(mut self) -> Self {
122        self.empty_messages = EmptyMessages::Keep;
123        self
124    }
125}
126
127/// Prunes `messages` according to `options`.
128#[must_use]
129pub fn prune(mut messages: Vec<Message>, options: &PruneOptions) -> Vec<Message> {
130    prune_reasoning(&mut messages, options.reasoning);
131    for rule in &options.tool_calls {
132        prune_tool_calls(&mut messages, rule);
133    }
134    if options.empty_messages == EmptyMessages::Remove {
135        messages.retain(|message| !message.is_empty());
136    }
137    messages
138}
139
140fn prune_reasoning(messages: &mut [Message], reasoning: ReasoningPrune) {
141    let last_index = messages.len().saturating_sub(1);
142    for (index, message) in messages.iter_mut().enumerate() {
143        let keep = match reasoning {
144            ReasoningPrune::None => true,
145            ReasoningPrune::All => false,
146            ReasoningPrune::BeforeLastMessage => index == last_index,
147        };
148        if keep {
149            continue;
150        }
151        if let Message::Assistant(assistant) = message
152            && let AssistantContent::Parts(parts) = &mut assistant.content
153        {
154            parts.retain(|part| !matches!(part, AssistantPart::Reasoning(_)));
155        }
156    }
157}
158
159enum ToolRef<'a> {
160    Call(&'a ToolCallId, &'a ToolName),
161    Approval(&'a ApprovalId, Option<&'a ToolCallId>),
162}
163
164fn assistant_refs(part: &AssistantPart) -> Option<ToolRef<'_>> {
165    match part {
166        AssistantPart::ToolCall(call) => Some(ToolRef::Call(&call.tool_call_id, &call.tool_name)),
167        AssistantPart::ToolResult(result) => {
168            Some(ToolRef::Call(&result.tool_call_id, &result.tool_name))
169        }
170        AssistantPart::ToolApprovalRequest(request) => Some(ToolRef::Approval(
171            &request.approval_id,
172            Some(&request.tool_call_id),
173        )),
174        _ => None,
175    }
176}
177
178fn tool_refs(part: &ToolPart) -> Option<ToolRef<'_>> {
179    match part {
180        ToolPart::ToolResult(result) => {
181            Some(ToolRef::Call(&result.tool_call_id, &result.tool_name))
182        }
183        ToolPart::ToolApprovalResponse(response) => {
184            Some(ToolRef::Approval(&response.approval_id, None))
185        }
186    }
187}
188
189fn message_refs(message: &Message) -> Vec<ToolRef<'_>> {
190    match message {
191        Message::Assistant(assistant) => assistant
192            .content
193            .as_parts()
194            .map(|parts| parts.iter().filter_map(assistant_refs).collect())
195            .unwrap_or_default(),
196        Message::Tool(tool) => tool.content.iter().filter_map(tool_refs).collect(),
197        Message::System(_) | Message::User(_) => Vec::new(),
198    }
199}
200
201struct Kept {
202    tool_call_ids: HashSet<ToolCallId>,
203    approval_ids: HashSet<ApprovalId>,
204    approval_tool_names: HashMap<ApprovalId, ToolName>,
205    protected_from: usize,
206}
207
208impl Kept {
209    fn keeps_call(
210        &self,
211        tool_call_id: &ToolCallId,
212        tool_name: &ToolName,
213        rule: &ToolCallPrune,
214    ) -> bool {
215        self.tool_call_ids.contains(tool_call_id) || rule_keeps(rule, Some(tool_name))
216    }
217
218    fn keeps_approval(&self, approval_id: &ApprovalId, rule: &ToolCallPrune) -> bool {
219        self.approval_ids.contains(approval_id)
220            || rule_keeps(rule, self.approval_tool_names.get(approval_id))
221    }
222}
223
224/// A part outside the protected tail survives only when the rule targets
225/// specific tools and the part's tool is known and not among them.
226fn rule_keeps(rule: &ToolCallPrune, tool_name: Option<&ToolName>) -> bool {
227    match (&rule.tools, tool_name) {
228        (Some(tools), Some(name)) => !tools.contains(name),
229        _ => false,
230    }
231}
232
233fn prune_tool_calls(messages: &mut [Message], rule: &ToolCallPrune) {
234    let keep_last = match rule.scope {
235        PruneScope::All => None,
236        PruneScope::BeforeLastMessages(n) => Some(n),
237    };
238    let protected_from = keep_last.map_or(messages.len(), |n| {
239        if n == 0 {
240            0
241        } else {
242            messages.len().saturating_sub(n)
243        }
244    });
245
246    let mut kept = Kept {
247        tool_call_ids: HashSet::new(),
248        approval_ids: HashSet::new(),
249        approval_tool_names: HashMap::new(),
250        protected_from,
251    };
252    for message in &messages[protected_from..] {
253        for reference in message_refs(message) {
254            match reference {
255                ToolRef::Call(id, _) => {
256                    kept.tool_call_ids.insert(id.clone());
257                }
258                ToolRef::Approval(id, _) => {
259                    kept.approval_ids.insert(id.clone());
260                }
261            }
262        }
263    }
264
265    let mut call_tool_names: HashMap<ToolCallId, ToolName> = HashMap::new();
266    let mut approval_calls: Vec<(ApprovalId, ToolCallId)> = Vec::new();
267    for message in messages.iter() {
268        for reference in message_refs(message) {
269            match reference {
270                ToolRef::Call(id, name) => {
271                    call_tool_names.insert(id.clone(), name.clone());
272                }
273                ToolRef::Approval(approval_id, Some(call_id)) => {
274                    approval_calls.push((approval_id.clone(), call_id.clone()));
275                }
276                ToolRef::Approval(_, None) => {}
277            }
278        }
279    }
280    for (approval_id, call_id) in approval_calls {
281        if let Some(name) = call_tool_names.get(&call_id) {
282            kept.approval_tool_names.insert(approval_id, name.clone());
283        }
284    }
285
286    for message in messages.iter_mut().take(kept.protected_from) {
287        match message {
288            Message::Assistant(assistant) => {
289                if let AssistantContent::Parts(parts) = &mut assistant.content {
290                    parts.retain(|part| match part {
291                        AssistantPart::ToolCall(call) => {
292                            kept.keeps_call(&call.tool_call_id, &call.tool_name, rule)
293                        }
294                        AssistantPart::ToolResult(result) => {
295                            kept.keeps_call(&result.tool_call_id, &result.tool_name, rule)
296                        }
297                        AssistantPart::ToolApprovalRequest(request) => {
298                            kept.keeps_approval(&request.approval_id, rule)
299                        }
300                        _ => true,
301                    });
302                }
303            }
304            Message::Tool(tool) => {
305                tool.content.retain(|part| match part {
306                    ToolPart::ToolResult(result) => {
307                        kept.keeps_call(&result.tool_call_id, &result.tool_name, rule)
308                    }
309                    ToolPart::ToolApprovalResponse(response) => {
310                        kept.keeps_approval(&response.approval_id, rule)
311                    }
312                });
313            }
314            Message::System(_) | Message::User(_) => {}
315        }
316    }
317}