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` behaves like
41    /// [`PruneScope::All`].
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 | PruneScope::BeforeLastMessages(0) => None,
236        PruneScope::BeforeLastMessages(n) => Some(n),
237    };
238    let protected_from = keep_last.map_or(messages.len(), |n| messages.len().saturating_sub(n));
239
240    let mut kept = Kept {
241        tool_call_ids: HashSet::new(),
242        approval_ids: HashSet::new(),
243        approval_tool_names: HashMap::new(),
244        protected_from,
245    };
246    for message in &messages[protected_from..] {
247        for reference in message_refs(message) {
248            match reference {
249                ToolRef::Call(id, _) => {
250                    kept.tool_call_ids.insert(id.clone());
251                }
252                ToolRef::Approval(id, _) => {
253                    kept.approval_ids.insert(id.clone());
254                }
255            }
256        }
257    }
258
259    let mut call_tool_names: HashMap<ToolCallId, ToolName> = HashMap::new();
260    let mut approval_calls: Vec<(ApprovalId, ToolCallId)> = Vec::new();
261    for message in messages.iter() {
262        for reference in message_refs(message) {
263            match reference {
264                ToolRef::Call(id, name) => {
265                    call_tool_names.insert(id.clone(), name.clone());
266                }
267                ToolRef::Approval(approval_id, Some(call_id)) => {
268                    approval_calls.push((approval_id.clone(), call_id.clone()));
269                }
270                ToolRef::Approval(_, None) => {}
271            }
272        }
273    }
274    for (approval_id, call_id) in approval_calls {
275        if let Some(name) = call_tool_names.get(&call_id) {
276            kept.approval_tool_names.insert(approval_id, name.clone());
277        }
278    }
279
280    for message in messages.iter_mut().take(kept.protected_from) {
281        match message {
282            Message::Assistant(assistant) => {
283                if let AssistantContent::Parts(parts) = &mut assistant.content {
284                    parts.retain(|part| match part {
285                        AssistantPart::ToolCall(call) => {
286                            kept.keeps_call(&call.tool_call_id, &call.tool_name, rule)
287                        }
288                        AssistantPart::ToolResult(result) => {
289                            kept.keeps_call(&result.tool_call_id, &result.tool_name, rule)
290                        }
291                        AssistantPart::ToolApprovalRequest(request) => {
292                            kept.keeps_approval(&request.approval_id, rule)
293                        }
294                        _ => true,
295                    });
296                }
297            }
298            Message::Tool(tool) => {
299                tool.content.retain(|part| match part {
300                    ToolPart::ToolResult(result) => {
301                        kept.keeps_call(&result.tool_call_id, &result.tool_name, rule)
302                    }
303                    ToolPart::ToolApprovalResponse(response) => {
304                        kept.keeps_approval(&response.approval_id, rule)
305                    }
306                });
307            }
308            Message::System(_) | Message::User(_) => {}
309        }
310    }
311}