1use std::collections::BTreeSet;
12
13use crate::message::{
14 self, AssistantContent, CallId, Message, ToolName, ToolResultContent, UserContent,
15};
16use crate::tool::ToolResult;
17
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
20pub enum TranscriptError {
21 #[error("consecutive assistant messages at index {index}")]
23 ConsecutiveAssistant {
24 index: usize,
26 },
27 #[error("tool call `{call_id}` at index {index} has no result in the following message")]
29 UnansweredToolCall {
30 index: usize,
32 call_id: CallId,
34 },
35 #[error(
38 "tool result `{call_id}` at index {index} answers no call from the preceding assistant message"
39 )]
40 OrphanToolResult {
41 index: usize,
43 call_id: CallId,
45 },
46}
47
48pub 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
108pub 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
119pub 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
131pub 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
140pub 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
145pub 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
167pub 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
177pub fn is_empty_assistant_turn(content: &[AssistantContent]) -> bool {
181 content.iter().all(AssistantContent::is_blank)
182}
183
184pub 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;