Skip to main content

rig_core/
transcript.rs

1//! Conversation validation and repair, assistant-turn classification, and
2//! constructors for real or synthetic tool results.
3//!
4//! ```
5//! use rig_core::{message::Message, transcript::validate_canonical};
6//!
7//! validate_canonical(&[Message::user("Hello"), Message::assistant("Hi")])?;
8//! # Ok::<(), rig_core::transcript::TranscriptError>(())
9//! ```
10
11use std::collections::BTreeSet;
12
13use crate::message::{
14    self, AssistantContent, CallId, Message, ToolName, ToolResultContent, UserContent,
15};
16use crate::tool::ToolResult;
17
18/// Why a history is not a canonical transcript. See [`validate_canonical`].
19#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
20pub enum TranscriptError {
21    /// Two assistant messages in a row (index of the second).
22    #[error("consecutive assistant messages at index {index}")]
23    ConsecutiveAssistant {
24        /// Index of the offending (second) assistant message.
25        index: usize,
26    },
27    /// An assistant tool call whose result is not in the next message.
28    #[error("tool call `{call_id}` at index {index} has no result in the following message")]
29    UnansweredToolCall {
30        /// Index of the assistant message carrying the call.
31        index: usize,
32        /// The unanswered call id.
33        call_id: CallId,
34    },
35    /// A tool result that answers no call from the immediately preceding
36    /// assistant message.
37    #[error(
38        "tool result `{call_id}` at index {index} answers no call from the preceding assistant message"
39    )]
40    OrphanToolResult {
41        /// Index of the user message carrying the result.
42        index: usize,
43        /// The orphan result's call id.
44        call_id: CallId,
45    },
46}
47
48/// Rejects consecutive assistant messages, unanswered tool-call IDs, and results
49/// without a pending call. Each pending ID must be answered once in the next user
50/// message, before another assistant message or the end of history.
51/// System messages reset the consecutive-assistant check but retain pending calls.
52/// Duplicate call IDs are treated as one pending ID.
53pub fn validate_canonical(messages: &[Message]) -> Result<(), TranscriptError> {
54    let mut prev_assistant_calls: Option<BTreeSet<CallId>> = None;
55    let mut prev_was_assistant = false;
56    for (index, message) in messages.iter().enumerate() {
57        match message {
58            Message::Assistant(turn) => {
59                if prev_was_assistant {
60                    return Err(TranscriptError::ConsecutiveAssistant { index });
61                }
62                if let Some(call_id) = prev_assistant_calls
63                    .take()
64                    .and_then(|pending| pending.into_iter().next())
65                {
66                    return Err(TranscriptError::UnansweredToolCall {
67                        index: index - 1,
68                        call_id,
69                    });
70                }
71                let calls: BTreeSet<CallId> =
72                    turn.tool_calls().map(|call| call.id.clone()).collect();
73                prev_assistant_calls = (!calls.is_empty()).then_some(calls);
74                prev_was_assistant = true;
75            }
76            Message::User { content } => {
77                let mut pending = prev_assistant_calls.take().unwrap_or_default();
78                for item in content.iter() {
79                    if let UserContent::ToolResult(result) = item {
80                        let id = result.call.clone();
81                        if !pending.remove(&id) {
82                            return Err(TranscriptError::OrphanToolResult { index, call_id: id });
83                        }
84                    }
85                }
86                if let Some(call_id) = pending.into_iter().next() {
87                    return Err(TranscriptError::UnansweredToolCall {
88                        index: index.saturating_sub(1),
89                        call_id,
90                    });
91                }
92                prev_was_assistant = false;
93            }
94            Message::System { .. } => {
95                prev_was_assistant = false;
96            }
97        }
98    }
99    if let Some(call_id) = prev_assistant_calls.and_then(|pending| pending.into_iter().next()) {
100        return Err(TranscriptError::UnansweredToolCall {
101            index: messages.len().saturating_sub(1),
102            call_id,
103        });
104    }
105    Ok(())
106}
107
108/// Shape a tool's result as the tool result the model reads, without
109/// reparsing text. Anything but a success is an error result.
110pub fn tool_result_output(call: CallId, name: ToolName, result: &ToolResult) -> UserContent {
111    UserContent::ToolResult(message::ToolResult {
112        call,
113        name,
114        content: result.output().clone().into_content(),
115        is_error: !result.is_success(),
116    })
117}
118
119/// Constructs the error result of a call that never ran, its text verbatim,
120/// such as recovery feedback or a skip reason. JSON-shaped text is not
121/// reinterpreted as structured or multimodal output.
122pub fn tool_result_message(call: CallId, name: ToolName, message: String) -> UserContent {
123    UserContent::ToolResult(message::ToolResult {
124        call,
125        name,
126        content: vec![ToolResultContent::text(message)],
127        is_error: true,
128    })
129}
130
131/// What the model reads when its call to `tool` sent `raw`, arguments that
132/// are not a JSON object: the tool never ran, and the model calls it again.
133pub fn invalid_arguments_feedback(tool: &str, raw: &str) -> String {
134    format!(
135        "The arguments for tool `{tool}` are not a JSON object: {raw}. \
136         Call the tool again with a JSON object as its arguments."
137    )
138}
139
140/// The result every other call of a turn gets when one call was retried or
141/// skipped: none of the turn's calls ran.
142pub const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
143    "Tool not executed because another tool call in the same assistant turn was invalid.";
144
145/// The tool results answering a turn with an invalid call, in call order:
146/// `feedback` for the call `invalid`, [`TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER`]
147/// for every other call. Empty when `content` has no tool calls.
148pub fn invalid_call_feedback(
149    content: &[AssistantContent],
150    invalid: &CallId,
151    feedback: &str,
152) -> Vec<UserContent> {
153    content
154        .iter()
155        .filter_map(|part| match part {
156            AssistantContent::ToolCall(call) if &call.id == invalid => Some(tool_result_message(
157                call.id.clone(),
158                call.function.name.clone(),
159                feedback.to_owned(),
160            )),
161            AssistantContent::ToolCall(call) => Some(not_executed(call)),
162            _ => None,
163        })
164        .collect()
165}
166
167/// The result of a call the run did not execute because a call beside it
168/// was invalid.
169pub fn not_executed(call: &crate::message::ToolCall) -> UserContent {
170    tool_result_message(
171        call.id.clone(),
172        call.function.name.clone(),
173        TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_owned(),
174    )
175}
176
177/// Whether a generated assistant turn is empty: every part is blank
178/// ([`AssistantContent::is_blank`], the rule replay drops parts by). An
179/// empty turn must not enter history.
180pub fn is_empty_assistant_turn(content: &[AssistantContent]) -> bool {
181    content.iter().all(AssistantContent::is_blank)
182}
183
184/// The text parts of an assistant turn, concatenated.
185pub fn assistant_text_from_choice(content: &[AssistantContent]) -> String {
186    content
187        .iter()
188        .filter_map(|part| match part {
189            AssistantContent::Text(text) => Some(text.text.as_str()),
190            _ => None,
191        })
192        .collect()
193}
194
195#[cfg(test)]
196mod validator_tests;