Skip to main content

rig_core/
transcript.rs

1//! Conversation validation and constructors for real or synthetic tool results.
2//!
3//! ```
4//! use rig_core::{message::Message, transcript::validate_canonical};
5//!
6//! validate_canonical(&[Message::user("Hello"), Message::assistant("Hi")])?;
7//! # Ok::<(), rig_core::transcript::TranscriptError>(())
8//! ```
9
10use std::collections::BTreeSet;
11
12use crate::message::{AssistantContent, CallId, Message, ToolName, ToolResultContent, UserContent};
13use crate::tool::ToolOutput;
14
15/// Why a history is not a canonical transcript. See [`validate_canonical`].
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum TranscriptError {
18    /// Two assistant messages in a row (index of the second).
19    #[error("consecutive assistant messages at index {index}")]
20    ConsecutiveAssistant {
21        /// Index of the offending (second) assistant message.
22        index: usize,
23    },
24    /// An assistant tool call whose result is not in the next message.
25    #[error("tool call `{call_id}` at index {index} has no result in the following message")]
26    UnansweredToolCall {
27        /// Index of the assistant message carrying the call.
28        index: usize,
29        /// The unanswered call id.
30        call_id: CallId,
31    },
32    /// A tool result that answers no call from the immediately preceding
33    /// assistant message.
34    #[error(
35        "tool result `{call_id}` at index {index} answers no call from the preceding assistant message"
36    )]
37    OrphanToolResult {
38        /// Index of the user message carrying the result.
39        index: usize,
40        /// The orphan result's call id.
41        call_id: CallId,
42    },
43}
44
45/// Rejects consecutive assistant messages, unanswered tool-call IDs, and results
46/// without a pending call. Each pending ID must be answered once in the next user
47/// message, before another assistant message or the end of history.
48/// System messages reset the consecutive-assistant check but retain pending calls.
49/// Duplicate call IDs are treated as one pending ID.
50pub fn validate_canonical(messages: &[Message]) -> Result<(), TranscriptError> {
51    let mut prev_assistant_calls: Option<BTreeSet<CallId>> = None;
52    let mut prev_was_assistant = false;
53    for (index, message) in messages.iter().enumerate() {
54        match message {
55            Message::Assistant { content, .. } => {
56                if prev_was_assistant {
57                    return Err(TranscriptError::ConsecutiveAssistant { index });
58                }
59                if let Some(call_id) = prev_assistant_calls
60                    .take()
61                    .and_then(|pending| pending.into_iter().next())
62                {
63                    return Err(TranscriptError::UnansweredToolCall {
64                        index: index - 1,
65                        call_id,
66                    });
67                }
68                let calls: BTreeSet<CallId> = content
69                    .iter()
70                    .filter_map(|c| match c {
71                        AssistantContent::ToolCall(call) => Some(call.id.clone()),
72                        _ => None,
73                    })
74                    .collect();
75                prev_assistant_calls = (!calls.is_empty()).then_some(calls);
76                prev_was_assistant = true;
77            }
78            Message::User { content } => {
79                let mut pending = prev_assistant_calls.take().unwrap_or_default();
80                for item in content.iter() {
81                    if let UserContent::ToolResult(result) = item {
82                        let id = result.call.clone();
83                        if !pending.remove(&id) {
84                            return Err(TranscriptError::OrphanToolResult { index, call_id: id });
85                        }
86                    }
87                }
88                if let Some(call_id) = pending.into_iter().next() {
89                    return Err(TranscriptError::UnansweredToolCall {
90                        index: index.saturating_sub(1),
91                        call_id,
92                    });
93                }
94                prev_was_assistant = false;
95            }
96            Message::System { .. } => {
97                prev_was_assistant = false;
98            }
99        }
100    }
101    if let Some(call_id) = prev_assistant_calls.and_then(|pending| pending.into_iter().next()) {
102        return Err(TranscriptError::UnansweredToolCall {
103            index: messages.len().saturating_sub(1),
104            call_id,
105        });
106    }
107    Ok(())
108}
109
110/// Shape a canonical real tool output as a tool result without reparsing text.
111pub fn tool_result_output(call: CallId, name: ToolName, output: ToolOutput) -> UserContent {
112    UserContent::tool_result(call, name, output.into_content())
113}
114
115/// Constructs a synthetic tool result containing verbatim text, such as recovery
116/// feedback or a skip reason. JSON-shaped text is not reinterpreted as structured
117/// or multimodal output.
118pub fn tool_result_message(call: CallId, name: ToolName, message: String) -> UserContent {
119    UserContent::tool_result(call, name, vec![ToolResultContent::text(message)])
120}
121
122#[cfg(test)]
123mod validator_tests;