1use std::collections::BTreeSet;
11
12use crate::message::{AssistantContent, CallId, Message, ToolName, ToolResultContent, UserContent};
13use crate::tool::ToolOutput;
14
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum TranscriptError {
18 #[error("consecutive assistant messages at index {index}")]
20 ConsecutiveAssistant {
21 index: usize,
23 },
24 #[error("tool call `{call_id}` at index {index} has no result in the following message")]
26 UnansweredToolCall {
27 index: usize,
29 call_id: CallId,
31 },
32 #[error(
35 "tool result `{call_id}` at index {index} answers no call from the preceding assistant message"
36 )]
37 OrphanToolResult {
38 index: usize,
40 call_id: CallId,
42 },
43}
44
45pub 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
110pub fn tool_result_output(call: CallId, name: ToolName, output: ToolOutput) -> UserContent {
112 UserContent::tool_result(call, name, output.into_content())
113}
114
115pub 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;