Skip to main content

rig_core/conversation/
pairing.rs

1//! The rules pairing a conversation's tool calls with their results:
2//! validation and repair of a message list, the same repair over entries
3//! that keeps their ids ([`normalize`]), the calls a cut-short conversation
4//! still owes, and where a window of it may start.
5//!
6//! ```
7//! use rig_core::{conversation::pairing, message::Message};
8//!
9//! pairing::validate(&[Message::user("Hello"), Message::assistant("Hi")])?;
10//! # Ok::<(), pairing::PairingError>(())
11//! ```
12
13use std::borrow::Borrow;
14use std::collections::HashSet;
15use std::sync::Arc;
16
17use super::{Author, Entry, MessageId};
18use crate::message::{
19    AssistantContent, AssistantMessage, CallId, Message, ToolCall, ToolName, UserContent,
20};
21use crate::tool::feedback::tool_result_message;
22
23/// Why a history is not a canonical conversation, or what [`pair`] repaired.
24/// See [`validate`].
25#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
26pub enum PairingError {
27    /// Two assistant messages in a row (index of the second).
28    #[error("consecutive assistant messages at index {index}")]
29    ConsecutiveAssistant {
30        /// Index of the offending (second) assistant message.
31        index: usize,
32    },
33    /// An assistant tool call nothing answered before the next assistant
34    /// message, a user message with more than results, or the end.
35    #[error("tool call `{call_id}` at index {index} has no result in the following message")]
36    UnansweredToolCall {
37        /// Index of the assistant message carrying the call.
38        index: usize,
39        /// The unanswered call id.
40        call_id: CallId,
41    },
42    /// A tool result that answers no call still waiting for one.
43    #[error(
44        "tool result `{call_id}` at index {index} answers no call from the preceding assistant message"
45    )]
46    OrphanToolResult {
47        /// Index of the user message carrying the result.
48        index: usize,
49        /// The orphan result's call id.
50        call_id: CallId,
51    },
52}
53
54/// Rejects what [`pair`] would repair: consecutive assistant messages, a
55/// call nothing answered, and a result no waiting call asked for.
56///
57/// Call ids are matched as a multiset in call order, so a turn that repeats
58/// an id is answered by one result per occurrence. A turn that ended in an
59/// error or was aborted owes no results. A turn's results may be split over
60/// adjacent user messages or around system messages, which reset the
61/// consecutive-assistant check but keep calls waiting.
62pub fn validate(messages: &[impl Borrow<Message>]) -> Result<(), PairingError> {
63    repair(
64        messages
65            .iter()
66            .map(|message| message.borrow().clone())
67            .collect(),
68    )
69    .repairs
70    .into_iter()
71    .next()
72    .map_or(Ok(()), Err)
73}
74
75/// The text of the result rig sends for a tool call nothing answered.
76pub const NO_RESULT_PROVIDED: &str = "No result provided";
77
78/// How [`pair`] reads a history.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct Pairing {
81    /// Results before the first assistant turn answer calls the provider
82    /// holds for a stored conversation, so they are kept, one per id.
83    pub stored: bool,
84    /// Every unanswered call gets a [`NO_RESULT_PROVIDED`] result. Without
85    /// it, as for a request that sends calls as text, none is made up.
86    pub answers: bool,
87}
88
89impl Pairing {
90    /// A history read on its own: nothing is stored, every call is answered.
91    pub const fn canonical() -> Self {
92        Self {
93            stored: false,
94            answers: true,
95        }
96    }
97}
98
99/// A history with its calls and results paired.
100#[derive(Debug, Clone, PartialEq)]
101pub struct Paired {
102    /// The paired history.
103    pub messages: Vec<Message>,
104    /// What was repaired, in order, by input index; empty for a canonical one.
105    pub repairs: Vec<PairingError>,
106}
107
108/// `messages` read as a canonical conversation and repaired into one
109/// [`validate`] accepts, losing no turn: the lenient load of a
110/// history from outside the protocol, such as a stored transcript, and the
111/// close of a run's history at cancellation. Beyond [`pair`], an assistant
112/// turn left directly after another is merged into it; `repairs` reports
113/// the input as [`pair`] read it.
114pub fn repair(messages: Vec<Message>) -> Paired {
115    let shaped = shape(messages, true);
116    Paired {
117        messages: shaped.messages,
118        repairs: shaped.repairs,
119    }
120}
121
122/// `entries` repaired as [`repair`] repairs messages, keeping ids: what
123/// the conversation holds after [`Conversation::normalize`].
124///
125/// [`Conversation::normalize`]: super::Conversation::normalize
126#[derive(Debug, Clone, PartialEq)]
127pub struct Normalized {
128    /// The repaired entries, each with the id, author and made-time of the
129    /// first entry it holds anything of; made-up results that no user
130    /// entry follows to take them are a new entry by [`Author::rig`].
131    pub entries: Vec<Entry>,
132    /// What was repaired, as [`repair`] reports it.
133    pub repairs: Vec<PairingError>,
134    /// Each entry merged into another: the id kept, and the id merged into
135    /// it.
136    pub merged: Vec<(MessageId, MessageId)>,
137    /// The ids of entries nothing was kept of, such as one holding only
138    /// results no call asked for.
139    pub dropped: Vec<MessageId>,
140}
141
142/// `entries` repaired as [`repair`] repairs their messages, but that user
143/// entries side by side stay apart: an assistant entry directly after
144/// another is merged into it (the later stop standing) and keeps the
145/// first's id, author and made-time; a result no call waits for is
146/// dropped, and an entry left empty with it; a made-up result for a call
147/// nothing answered goes into the next user entry, else a new entry by
148/// [`Author::rig`]; a system entry arriving while calls wait moves after
149/// the entries that answer them. A list [`repair`] leaves as it is comes
150/// back unchanged.
151pub fn normalize(entries: Vec<Entry>) -> Normalized {
152    let (messages, metas): (Vec<Message>, Vec<Meta>) = entries
153        .into_iter()
154        .map(|entry| {
155            let message = Arc::unwrap_or_clone(entry.message);
156            (message, (entry.author, entry.id, entry.made))
157        })
158        .unzip();
159    let shaped = shape(messages, false);
160    let mut used = HashSet::new();
161    let mut merged = Vec::new();
162    let mut entries = Vec::with_capacity(shaped.messages.len());
163    for (message, sources) in shaped.messages.into_iter().zip(shaped.sources) {
164        let mut free = sources.iter().filter(|source| used.insert(**source));
165        let kept = free.next().and_then(|source| metas.get(*source));
166        let into = kept.and_then(|(_, id, _)| *id);
167        for gone in free.filter_map(|source| metas.get(*source)?.1) {
168            if let Some(into) = into {
169                merged.push((into, gone));
170            }
171        }
172        let entry = match kept {
173            Some((author, id, made)) => Entry {
174                message: Arc::new(message),
175                author: author.clone(),
176                id: *id,
177                made: *made,
178            },
179            // Part of an entry already kept, by its author, or results
180            // made up, by rig.
181            None => {
182                let first = sources.first().and_then(|source| metas.get(*source));
183                Entry {
184                    message: Arc::new(message),
185                    author: first
186                        .map_or_else(|| Some(Author::rig()), |(author, ..)| author.clone()),
187                    id: None,
188                    made: None,
189                }
190            }
191        };
192        entries.push(entry);
193    }
194    let dropped = metas.iter().enumerate();
195    let dropped = dropped.filter(|(source, _)| !used.contains(source));
196    Normalized {
197        entries,
198        repairs: shaped.repairs,
199        merged,
200        dropped: dropped.filter_map(|(_, (_, id, _))| *id).collect(),
201    }
202}
203
204/// An entry without its message: author, id and made-time.
205type Meta = (Option<Author>, Option<MessageId>, Option<u64>);
206
207/// Messages after [`pair`]'s walk and [`merge_turns`], each with the input
208/// indices it holds anything of (the first is the one it stands for), and
209/// what the first walk repaired.
210struct Shaped {
211    messages: Vec<Message>,
212    sources: Vec<Vec<usize>>,
213    repairs: Vec<PairingError>,
214}
215
216/// `messages` read as a canonical conversation and paired, with adjacent
217/// user messages made one when `merge_users`, then adjacent turns merged,
218/// paired again until none is left.
219fn shape(messages: Vec<Message>, merge_users: bool) -> Shaped {
220    let canonical = |messages: Vec<Message>| {
221        let history = messages.into_iter().map(Some).collect();
222        walk(history, Pairing::canonical(), merge_users, true)
223    };
224    let mut shaped = canonical(messages);
225    // Each merge removes a turn, so this ends.
226    while merge_turns(&mut shaped) {
227        let again = canonical(std::mem::take(&mut shaped.messages));
228        let sources = again.sources.iter().map(|sources| {
229            let earlier = sources.iter().filter_map(|at| shaped.sources.get(*at));
230            earlier.flatten().copied().collect()
231        });
232        shaped.sources = sources.collect();
233        shaped.messages = again.messages;
234    }
235    shaped
236}
237
238/// Merge every assistant turn directly after another into it, the later
239/// turn's stop standing; whether any was merged.
240fn merge_turns(shaped: &mut Shaped) -> bool {
241    let before = shaped.messages.len();
242    let mut messages: Vec<Message> = Vec::with_capacity(before);
243    let mut sources: Vec<Vec<usize>> = Vec::with_capacity(before);
244    let all = shaped.messages.drain(..).zip(shaped.sources.drain(..));
245    for (mut message, mut from) in all {
246        if let Message::Assistant(turn) = &mut message
247            && let Some(Message::Assistant(previous)) = messages.last_mut()
248        {
249            previous.content.append(&mut turn.content);
250            previous.stop = turn.stop.take();
251            if let Some(into) = sources.last_mut() {
252                into.append(&mut from);
253            }
254            continue;
255        }
256        messages.push(message);
257        sources.push(from);
258    }
259    shaped.messages = messages;
260    shaped.sources = sources;
261    shaped.messages.len() < before
262}
263
264/// The one rule for when a tool call is answered.
265/// `None` is a turn the caller emptied; it ends waiting calls like a turn.
266///
267/// - A result answers the first call of the turn before it still waiting
268///   with its id; a result nothing waits for is dropped.
269/// - A turn that ended in an error or was aborted is kept; its calls wait
270///   for nothing.
271/// - A call still waiting at a user message with more than results, the
272///   next turn or the end gets a [`close_pending`] result, before the user
273///   message's own content.
274/// - Adjacent non-empty user messages become one, and a system message
275///   arriving while calls wait is held until they are answered.
276pub fn pair(history: Vec<Option<Message>>, pairing: Pairing) -> Paired {
277    let shaped = walk(history, pairing, true, false);
278    Paired {
279        messages: shaped.messages,
280        repairs: shaped.repairs,
281    }
282}
283
284/// [`pair`]'s walk, noting where each message came from when `sourced`.
285/// Without `merge_users`, the content of adjacent user messages answers
286/// calls as one but stays in the message it came from, and a held system
287/// message goes after them whole.
288fn walk(
289    history: Vec<Option<Message>>,
290    pairing: Pairing,
291    merge_users: bool,
292    sourced: bool,
293) -> Shaped {
294    // The ids results before the first turn took, when calls are stored.
295    let mut stored = pairing.stored.then(Vec::new);
296    let mut walk = Walk {
297        answers: pairing.answers,
298        merge_users,
299        sourced,
300        ..Walk::default()
301    };
302    for step in steps(history) {
303        match step {
304            Step::Turn(index, turn) => {
305                if walk.after_assistant && turn.is_some() {
306                    walk.repairs
307                        .push(PairingError::ConsecutiveAssistant { index });
308                }
309                walk.close();
310                stored = None;
311                walk.gap = turn.is_none();
312                walk.after_assistant = turn.is_some();
313                let Some(turn) = turn else { continue };
314                if !turn.stop.as_ref().is_some_and(|stop| stop.is_failure()) {
315                    let calls = turn.tool_calls();
316                    let calls = calls.map(|call| (call.id.clone(), call.function.name.clone()));
317                    walk.waiting = calls.collect();
318                    walk.waiting_at = index;
319                }
320                walk.put(Message::Assistant(turn), [index]);
321            }
322            Step::System(index, system) => {
323                walk.after_assistant = false;
324                if walk.waiting.is_empty() {
325                    walk.gap = false;
326                    walk.put(system, [index]);
327                } else {
328                    walk.held.push((index, system));
329                }
330            }
331            Step::User(first, parts) => {
332                walk.after_assistant = false;
333                if parts.is_empty() {
334                    walk.close();
335                    let empty = Message::User {
336                        content: Vec::new(),
337                    };
338                    walk.put(empty, [first]);
339                    walk.gap = false;
340                    continue;
341                }
342                let mut content = Vec::with_capacity(parts.len());
343                for (index, part) in parts {
344                    let UserContent::ToolResult(result) = part else {
345                        content.push((Some(index), part));
346                        continue;
347                    };
348                    let answers = match &mut stored {
349                        Some(taken) if !taken.contains(&result.call) => {
350                            taken.push(result.call.clone());
351                            true
352                        }
353                        Some(_) => false,
354                        None => answer(&mut walk.waiting, &result.call, |(id, _)| id),
355                    };
356                    if answers {
357                        content.push((Some(index), UserContent::ToolResult(result)));
358                    } else {
359                        walk.repairs.push(PairingError::OrphanToolResult {
360                            index,
361                            call_id: result.call,
362                        });
363                    }
364                }
365                if content.is_empty() && walk.waiting.is_empty() {
366                    // Only results nothing waits for: the gap stays open.
367                    continue;
368                }
369                let only_results =
370                    !content.is_empty() && content.iter().all(|(_, part)| is_result(part));
371                if walk.pending.is_empty() {
372                    walk.pending_gap = walk.gap;
373                }
374                walk.pending.extend(content);
375                walk.gap = false;
376                // pi holds a system message while calls wait, so results
377                // split around one still answer the turn.
378                if only_results && !walk.waiting.is_empty() {
379                    continue;
380                }
381                walk.close();
382            }
383        }
384    }
385    walk.close();
386    Shaped {
387        messages: walk.shaped,
388        sources: walk.sources,
389        repairs: walk.repairs,
390    }
391}
392
393pub(crate) fn is_result(part: &UserContent) -> bool {
394    matches!(part, UserContent::ToolResult(_))
395}
396
397/// Why a batch of results does not answer a turn's calls ([`answers`]).
398#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
399pub enum AnswerError {
400    /// A part of the batch is not a tool result.
401    #[error("received content that is not a tool result")]
402    NotAResult,
403    /// A result for an id no call has, or whose every call was answered.
404    #[error("received a result for unknown or already-answered tool call id `{0}`")]
405    Unknown(CallId),
406    /// The calls the batch left unanswered, in call order.
407    #[error("left pending tool call id(s) unanswered: {0:?}")]
408    Unanswered(Vec<CallId>),
409}
410
411/// Whether `results` answer `pending` exactly, one result per occurrence of
412/// an id in any order, matched as [`pair`] matches them.
413pub fn answers(pending: &[CallId], results: &[UserContent]) -> Result<(), AnswerError> {
414    let mut waiting = pending.to_vec();
415    for result in results {
416        let UserContent::ToolResult(result) = result else {
417            return Err(AnswerError::NotAResult);
418        };
419        if !answer(&mut waiting, &result.call, |id| id) {
420            return Err(AnswerError::Unknown(result.call.clone()));
421        }
422    }
423    if waiting.is_empty() {
424        Ok(())
425    } else {
426        Err(AnswerError::Unanswered(waiting))
427    }
428}
429
430/// The user message closing `calls` nothing answered: a [`NO_RESULT_PROVIDED`]
431/// error result per occurrence, in call order, exactly as [`pair`] closes them.
432pub fn close_pending<'a>(calls: impl IntoIterator<Item = &'a ToolCall>) -> Message {
433    close_pending_with(calls, NO_RESULT_PROVIDED)
434}
435
436/// [`close_pending`], each error result saying `why`, such as why the
437/// calls never ran.
438pub fn close_pending_with<'a>(calls: impl IntoIterator<Item = &'a ToolCall>, why: &str) -> Message {
439    let calls = calls.into_iter();
440    Message::User {
441        content: unanswered(calls.map(|call| (&call.id, &call.function.name)), why),
442    }
443}
444
445fn unanswered<'a>(
446    calls: impl IntoIterator<Item = (&'a CallId, &'a ToolName)>,
447    why: &str,
448) -> Vec<UserContent> {
449    calls
450        .into_iter()
451        .map(|(id, name)| tool_result_message(id.clone(), name.clone(), why.to_owned()))
452        .collect()
453}
454
455/// Take the first of `waiting` with the id `id`; false when none has it.
456fn answer<T>(waiting: &mut Vec<T>, id: &CallId, id_of: impl Fn(&T) -> &CallId) -> bool {
457    match waiting.iter().position(|call| id_of(call) == id) {
458        Some(at) => {
459            waiting.remove(at);
460            true
461        }
462        None => false,
463    }
464}
465
466/// One step of [`pair`]'s walk, with indices into its input.
467enum Step {
468    /// An assistant turn, or `None` for one the caller emptied.
469    Turn(usize, Option<AssistantMessage>),
470    System(usize, Message),
471    /// A run of adjacent non-empty user messages, or one empty one: the
472    /// index of the first, and the index each part came from.
473    User(usize, Vec<(usize, UserContent)>),
474}
475
476/// `history` as steps, each run of adjacent non-empty user messages made
477/// one, so results split over several messages answer the turn before them.
478fn steps(history: Vec<Option<Message>>) -> Vec<Step> {
479    let mut steps: Vec<Step> = Vec::with_capacity(history.len());
480    for (index, message) in history.into_iter().enumerate() {
481        match message {
482            None => steps.push(Step::Turn(index, None)),
483            Some(Message::Assistant(turn)) => steps.push(Step::Turn(index, Some(turn))),
484            Some(system @ Message::System { .. }) => steps.push(Step::System(index, system)),
485            Some(Message::User { content }) => {
486                let joins = !content.is_empty();
487                let parts = content.into_iter().map(|part| (index, part));
488                match steps.last_mut() {
489                    Some(Step::User(_, previous)) if joins && !previous.is_empty() => {
490                        previous.extend(parts)
491                    }
492                    _ => steps.push(Step::User(index, parts.collect())),
493                }
494            }
495        }
496    }
497    steps
498}
499
500/// The state of [`pair`]'s walk.
501#[derive(Default)]
502struct Walk {
503    answers: bool,
504    /// Whether the content of adjacent user messages becomes one message.
505    merge_users: bool,
506    /// Whether `sources` is kept.
507    sourced: bool,
508    shaped: Vec<Message>,
509    /// Parallel to `shaped` when `sourced`: the input indices each holds
510    /// anything of.
511    sources: Vec<Vec<usize>>,
512    repairs: Vec<PairingError>,
513    /// Every occurrence of the last turn's calls still waiting, in call
514    /// order, and the turn's index.
515    waiting: Vec<(CallId, ToolName)>,
516    waiting_at: usize,
517    /// System messages that arrived while calls waited, with their indices.
518    held: Vec<(usize, Message)>,
519    /// Whether a turn was emptied since the last message pushed.
520    gap: bool,
521    /// Results answering some waiting calls while others still wait, and
522    /// content after them, each with its index (none for one made up).
523    pending: Vec<(Option<usize>, UserContent)>,
524    pending_gap: bool,
525    after_assistant: bool,
526}
527
528impl Walk {
529    fn put(&mut self, message: Message, sources: impl IntoIterator<Item = usize>) {
530        self.shaped.push(message);
531        if self.sourced {
532            self.sources.push(sources.into_iter().collect());
533        }
534    }
535
536    /// Push the pending user content with a result added for each waiting
537    /// call, then the held system messages. The results go before its first
538    /// non-result part, in call order. Nothing is pushed for empty content,
539    /// and content after a gap is appended to a user message that ends
540    /// `shaped`. Without `merge_users`, each message's content stays its
541    /// own, the made-up results go into the first, and the held system
542    /// messages follow them all.
543    fn close(&mut self) {
544        let mut content = std::mem::take(&mut self.pending);
545        let merge = std::mem::take(&mut self.pending_gap);
546        let waiting = std::mem::take(&mut self.waiting);
547        let missing =
548            if self.answers {
549                let index = self.waiting_at;
550                self.repairs.extend(waiting.iter().map(|(id, _)| {
551                    PairingError::UnansweredToolCall {
552                        index,
553                        call_id: id.clone(),
554                    }
555                }));
556                unanswered(
557                    waiting.iter().map(|(id, name)| (id, name)),
558                    NO_RESULT_PROVIDED,
559                )
560            } else {
561                Vec::new()
562            };
563        if !self.merge_users {
564            self.close_apart(content, missing);
565            return;
566        }
567        let at = content
568            .iter()
569            .position(|(_, part)| !is_result(part))
570            .unwrap_or(content.len());
571        content.splice(at..at, missing.into_iter().map(|part| (None, part)));
572        // Results come first: Anthropic requires it, and Chat sends them as
573        // tool messages that must follow the turn directly.
574        content.sort_by_key(|(_, part)| !is_result(part));
575        // A held system message goes right after the results, before the
576        // user's own text, as pi places it.
577        let at = content
578            .iter()
579            .position(|(_, part)| !is_result(part))
580            .unwrap_or(content.len());
581        let text = if self.held.is_empty() {
582            Vec::new()
583        } else {
584            content.split_off(at)
585        };
586        if !content.is_empty() {
587            let (sources, content) = sourced(content);
588            match self.shaped.last_mut() {
589                // Only `pair` leaves a gap to merge across, and it keeps no
590                // sources.
591                Some(Message::User { content: previous }) if merge => previous.extend(content),
592                _ => self.put(Message::User { content }, sources),
593            }
594        }
595        for (index, system) in std::mem::take(&mut self.held) {
596            self.put(system, [index]);
597        }
598        if !text.is_empty() {
599            let (sources, content) = sourced(text);
600            self.put(Message::User { content }, sources);
601        }
602    }
603
604    /// [`close`](Self::close) without `merge_users`: each input message's
605    /// pending content as its own message, results first, the `missing`
606    /// results in the first (else a message of their own), then the held
607    /// system messages.
608    fn close_apart(
609        &mut self,
610        content: Vec<(Option<usize>, UserContent)>,
611        missing: Vec<UserContent>,
612    ) {
613        let mut messages: Vec<(Option<usize>, Vec<UserContent>)> = Vec::new();
614        for (source, part) in content {
615            match messages.last_mut() {
616                Some((last, parts)) if *last == source => parts.push(part),
617                _ => messages.push((source, vec![part])),
618            }
619        }
620        match messages.first_mut() {
621            Some((_, parts)) => {
622                let at = parts.iter().position(|part| !is_result(part));
623                let at = at.unwrap_or(parts.len());
624                parts.splice(at..at, missing);
625            }
626            None if !missing.is_empty() => messages.push((None, missing)),
627            None => {}
628        }
629        for (source, mut content) in messages {
630            content.sort_by_key(|part| !is_result(part));
631            self.put(Message::User { content }, source);
632        }
633        for (index, system) in std::mem::take(&mut self.held) {
634            self.put(system, [index]);
635        }
636    }
637}
638
639/// `content` without its indices, and the indices it holds, each once in
640/// the order first met.
641fn sourced(content: Vec<(Option<usize>, UserContent)>) -> (Vec<usize>, Vec<UserContent>) {
642    let mut sources: Vec<usize> = Vec::new();
643    let parts = content.into_iter().map(|(source, part)| {
644        if let Some(source) = source
645            && !sources.contains(&source)
646        {
647            sources.push(source);
648        }
649        part
650    });
651    let parts = parts.collect();
652    (sources, parts)
653}
654
655/// The tool calls of the last assistant message in `messages` that no
656/// later tool result answers, in call order: what a conversation cut short
657/// still owes the model.
658pub fn pending_calls(messages: &[impl Borrow<Message>]) -> Vec<ToolCall> {
659    let Some((at, Message::Assistant(reply))) = messages
660        .iter()
661        .map(Borrow::borrow)
662        .enumerate()
663        .rfind(|(_, message)| matches!(message, Message::Assistant(_)))
664    else {
665        return Vec::new();
666    };
667    let answered: Vec<&CallId> = messages
668        .iter()
669        .skip(at + 1)
670        .flat_map(|message| match message.borrow() {
671            Message::User { content } => content.as_slice(),
672            _ => &[],
673        })
674        .filter_map(|item| match item {
675            UserContent::ToolResult(result) => Some(&result.call),
676            _ => None,
677        })
678        .collect();
679    reply
680        .content
681        .iter()
682        .filter_map(|item| match item {
683            AssistantContent::ToolCall(call) if !answered.contains(&&call.id) => Some(call.clone()),
684            _ => None,
685        })
686        .collect()
687}
688
689/// The first index at or after `at` a request may start `messages` from:
690/// past every tool result before the first assistant turn from `at`, since
691/// its call is cut off, with any text or system message among them. Paired
692/// results later on are left alone. `at` past the end is the end.
693pub fn safe_start(messages: &[impl Borrow<Message>], at: usize) -> usize {
694    let mut start = at.min(messages.len());
695    for (index, message) in messages.iter().enumerate().skip(at) {
696        match message.borrow() {
697            Message::Assistant(_) => break,
698            Message::User { content }
699                if content
700                    .iter()
701                    .any(|item| matches!(item, UserContent::ToolResult(_))) =>
702            {
703                start = index + 1;
704            }
705            _ => {}
706        }
707    }
708    start
709}
710
711#[cfg(test)]
712mod tests;