use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::typed_id::{FileId, ImageId, MessageId, ModelId};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
use everruns_provider::execution_phase::{ExecutionPhase, PhaseSource};
use everruns_provider::reasoning::ReasoningContentPart;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(as = RuntimeMessageRole))]
#[serde(rename_all = "snake_case")]
pub enum MessageRole {
System,
User,
Agent,
ToolResult,
}
impl std::fmt::Display for MessageRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MessageRole::System => write!(f, "system"),
MessageRole::User => write!(f, "user"),
MessageRole::Agent => write!(f, "agent"),
MessageRole::ToolResult => write!(f, "tool_result"),
}
}
}
impl From<&str> for MessageRole {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"system" => MessageRole::System,
"user" => MessageRole::User,
"agent" | "assistant" => MessageRole::Agent,
"tool_result" => MessageRole::ToolResult,
_ => MessageRole::User,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ExternalActor {
pub actor_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor_name: Option<String>,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::HashMap<String, String>>,
}
impl ExternalActor {
pub fn display_label(&self) -> &str {
self.actor_name.as_deref().unwrap_or(&self.actor_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<everruns_provider::model::ReasoningEffort>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Controls {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
pub model_id: Option<ModelId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verbosity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_disclosure: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub hints: Option<std::collections::HashMap<String, serde_json::Value>>,
}
impl Controls {
pub fn resolve_hints(
session_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
message_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
) -> std::collections::HashMap<String, serde_json::Value> {
match (session_hints, message_hints) {
(None, None) => std::collections::HashMap::new(),
(Some(s), None) => s.clone(),
(None, Some(m)) => m.clone(),
(Some(s), Some(m)) => {
let mut merged = s.clone();
merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
merged
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(as = RuntimeMessage))]
pub struct Message {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
pub id: MessageId,
pub role: MessageRole,
pub content: Vec<ContentPart>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<ExecutionPhase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase_source: Option<PhaseSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controls: Option<Controls>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_actor: Option<ExternalActor>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ContentType {
Text,
Image,
ImageFile,
File,
ToolCall,
ToolResult,
Reasoning,
}
impl std::fmt::Display for ContentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContentType::Text => write!(f, "text"),
ContentType::Image => write!(f, "image"),
ContentType::ImageFile => write!(f, "image_file"),
ContentType::File => write!(f, "file"),
ContentType::ToolCall => write!(f, "tool_call"),
ContentType::ToolResult => write!(f, "tool_result"),
ContentType::Reasoning => write!(f, "reasoning"),
}
}
}
impl From<&str> for ContentType {
fn from(s: &str) -> Self {
match s {
"image" => ContentType::Image,
"image_file" => ContentType::ImageFile,
"tool_call" => ContentType::ToolCall,
"tool_result" => ContentType::ToolResult,
"reasoning" => ContentType::Reasoning,
_ => ContentType::Text,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TextContentPart {
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<TextAnnotation>,
}
impl TextContentPart {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
annotations: Vec::new(),
}
}
pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
self.annotations = annotations;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TextAnnotation {
#[cfg_attr(feature = "openapi", schema(example = 0))]
pub start: usize,
#[cfg_attr(feature = "openapi", schema(example = 19))]
pub end: usize,
#[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
pub origin: String,
pub source: AnnotationSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
pub external_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verified: Option<VerificationVerdict>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AnnotationSource {
#[cfg_attr(
feature = "openapi",
schema(example = "github://owner/repo@main/docs/x.md")
)]
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "openapi",
schema(example = "The control plane owns durable state.")
)]
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub location: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct VerificationVerdict {
pub status: VerificationStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(example = 0.92))]
pub score: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "entailed"))]
#[serde(rename_all = "snake_case")]
pub enum VerificationStatus {
Entailed,
Unsupported,
Uncertain,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ImageContentPart {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base64: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
}
impl ImageContentPart {
pub fn from_url(url: impl Into<String>) -> Self {
Self {
url: Some(url.into()),
base64: None,
media_type: None,
}
}
pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
Self {
url: None,
base64: Some(base64.into()),
media_type: Some(media_type.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ImageFileContentPart {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
pub image_id: ImageId,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
impl ImageFileContentPart {
pub fn new(image_id: ImageId) -> Self {
Self {
image_id,
filename: None,
}
}
pub fn with_filename(image_id: ImageId, filename: impl Into<String>) -> Self {
Self {
image_id,
filename: Some(filename.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct FileContentPart {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "file_01933b5a00007000800000000000001"))]
pub file_id: FileId,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
impl FileContentPart {
pub fn new(file_id: FileId) -> Self {
Self {
file_id,
filename: None,
}
}
pub fn with_filename(file_id: FileId, filename: impl Into<String>) -> Self {
Self {
file_id,
filename: Some(filename.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCallContentPart {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native: Option<everruns_provider::native_async::NativeToolCall>,
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
impl ToolCallContentPart {
pub fn from_native(
call: everruns_provider::native_async::NativeToolCall,
) -> crate::error::Result<Self> {
use everruns_provider::native_async::NativeToolCall;
call.validate()?;
let arguments = match &call {
NativeToolCall::Function { arguments, .. } => serde_json::from_str(arguments)
.map_err(|error| crate::error::AgentLoopError::llm(error.to_string()))?,
NativeToolCall::Custom { input, .. } => serde_json::Value::String(input.clone()),
};
Ok(Self {
id: call.id().into(),
name: call.name().into(),
arguments,
native: Some(call),
})
}
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
Self {
native: None,
id: id.into(),
name: name.into(),
arguments,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolResultContentPart {
pub tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl ToolResultContentPart {
pub fn new(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
error: Option<String>,
) -> Self {
Self {
tool_call_id: tool_call_id.into(),
result,
error,
}
}
pub fn success(tool_call_id: impl Into<String>, result: serde_json::Value) -> Self {
Self {
tool_call_id: tool_call_id.into(),
result: Some(result),
error: None,
}
}
pub fn error(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
Self {
tool_call_id: tool_call_id.into(),
result: None,
error: Some(error.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text(TextContentPart),
Image(ImageContentPart),
ImageFile(ImageFileContentPart),
File(FileContentPart),
ToolCall(ToolCallContentPart),
ToolResult(ToolResultContentPart),
Reasoning(ReasoningContentPart),
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
ContentPart::Text(TextContentPart::new(text))
}
pub fn tool_result_text(value: &serde_json::Value) -> Self {
match value {
serde_json::Value::String(text) => Self::text(text.clone()),
other => Self::text(other.to_string()),
}
}
pub fn image_url(url: impl Into<String>) -> Self {
ContentPart::Image(ImageContentPart::from_url(url))
}
pub fn image_file(image_id: ImageId) -> Self {
ContentPart::ImageFile(ImageFileContentPart::new(image_id))
}
pub fn file(file_id: FileId) -> Self {
ContentPart::File(FileContentPart::new(file_id))
}
pub fn tool_call(
id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
}
pub fn tool_result(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
error: Option<String>,
) -> Self {
ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
}
pub fn reasoning(part: ReasoningContentPart) -> Self {
ContentPart::Reasoning(part)
}
pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
match self {
ContentPart::Reasoning(r) => Some(r),
_ => None,
}
}
pub fn is_reasoning(&self) -> bool {
matches!(self, ContentPart::Reasoning(_))
}
pub fn as_text(&self) -> Option<&str> {
match self {
ContentPart::Text(t) => Some(&t.text),
_ => None,
}
}
pub fn is_image_file(&self) -> bool {
matches!(self, ContentPart::ImageFile(_))
}
pub fn is_file(&self) -> bool {
matches!(self, ContentPart::File(_))
}
pub fn content_type(&self) -> ContentType {
match self {
ContentPart::Text(_) => ContentType::Text,
ContentPart::Image(_) => ContentType::Image,
ContentPart::ImageFile(_) => ContentType::ImageFile,
ContentPart::File(_) => ContentType::File,
ContentPart::ToolCall(_) => ContentType::ToolCall,
ContentPart::ToolResult(_) => ContentType::ToolResult,
ContentPart::Reasoning(_) => ContentType::Reasoning,
}
}
pub fn to_openai_format(&self) -> Option<serde_json::Value> {
match self {
ContentPart::Text(t) => Some(serde_json::json!({
"type": "text",
"text": t.text
})),
ContentPart::Image(img) => {
if let Some(url) = &img.url {
Some(serde_json::json!({
"type": "image_url",
"image_url": { "url": url }
}))
} else if let Some(b64) = &img.base64 {
let media_type = img.media_type.as_deref().unwrap_or("image/png");
Some(serde_json::json!({
"type": "image_url",
"image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
}))
} else {
None
}
}
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputContentPart {
Text(TextContentPart),
Image(ImageContentPart),
ImageFile(ImageFileContentPart),
File(FileContentPart),
}
impl From<InputContentPart> for ContentPart {
fn from(input: InputContentPart) -> Self {
match input {
InputContentPart::Text(t) => ContentPart::Text(t),
InputContentPart::Image(i) => ContentPart::Image(i),
InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
InputContentPart::File(f) => ContentPart::File(f),
}
}
}
impl InputContentPart {
pub fn text(text: impl Into<String>) -> Self {
InputContentPart::Text(TextContentPart::new(text))
}
pub fn image_url(url: impl Into<String>) -> Self {
InputContentPart::Image(ImageContentPart::from_url(url))
}
pub fn image_file(image_id: ImageId) -> Self {
InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
}
pub fn file(file_id: FileId) -> Self {
InputContentPart::File(FileContentPart::new(file_id))
}
pub fn as_text(&self) -> Option<&str> {
match self {
InputContentPart::Text(t) => Some(&t.text),
_ => None,
}
}
pub fn content_type(&self) -> ContentType {
match self {
InputContentPart::Text(_) => ContentType::Text,
InputContentPart::Image(_) => ContentType::Image,
InputContentPart::ImageFile(_) => ContentType::ImageFile,
InputContentPart::File(_) => ContentType::File,
}
}
}
impl Message {
pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
self.content.iter().filter_map(ContentPart::as_reasoning)
}
pub fn has_reasoning(&self) -> bool {
self.content.iter().any(ContentPart::is_reasoning)
}
pub fn reasoning_display_text(&self) -> Option<String> {
let joined = self
.reasoning_parts()
.filter_map(ReasoningContentPart::display_text)
.collect::<Vec<_>>()
.join("\n\n");
(!joined.is_empty()).then_some(joined)
}
pub fn into_public(mut self) -> Self {
for part in &mut self.content {
if let ContentPart::Reasoning(r) = part {
*r = r.to_public();
}
}
self
}
pub fn with_id(mut self, id: MessageId) -> Self {
self.id = id;
self
}
pub fn user(content: impl Into<String>) -> Self {
Self {
id: MessageId::new(),
role: MessageRole::User,
content: vec![ContentPart::text(content)],
phase: None,
phase_source: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
id: MessageId::new(),
role: MessageRole::Agent,
content: vec![ContentPart::text(content)],
phase: None,
phase_source: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn assistant_with_tools(
content: impl Into<String>,
tool_calls: Vec<crate::tool_types::ToolCall>,
) -> Self {
let text_content = content.into();
let mut parts = Vec::new();
if !text_content.is_empty() {
parts.push(ContentPart::text(text_content));
}
for tc in tool_calls {
parts.push(ContentPart::ToolCall(ToolCallContentPart {
native: None,
id: tc.id,
name: tc.name,
arguments: tc.arguments,
}));
}
Self {
id: MessageId::new(),
role: MessageRole::Agent,
content: parts,
phase: None,
phase_source: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn system(content: impl Into<String>) -> Self {
Self {
id: MessageId::new(),
role: MessageRole::System,
content: vec![ContentPart::text(content)],
phase: None,
phase_source: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn tool_result(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
error: Option<String>,
) -> Self {
let tool_call_id = tool_call_id.into();
Self {
id: MessageId::new(),
role: MessageRole::ToolResult,
content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
tool_call_id,
result,
error,
))],
phase: None,
phase_source: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn tool_result_with_images(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
images: Vec<everruns_provider::tool_types::ToolResultImage>,
) -> Self {
let tool_call_id = tool_call_id.into();
let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
tool_call_id,
result,
None,
))];
for img in images {
content.push(ContentPart::Image(ImageContentPart::from_base64(
img.base64,
img.media_type,
)));
}
Self {
id: MessageId::new(),
role: MessageRole::ToolResult,
content,
phase: None,
phase_source: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
self.phase = Some(phase);
self
}
pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
self.phase = Some(phase);
self.phase_source = Some(source);
self
}
pub fn tool_call_id(&self) -> Option<&str> {
self.content.iter().find_map(|p| match p {
ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
_ => None,
})
}
pub fn text(&self) -> Option<&str> {
self.content.iter().find_map(|p| p.as_text())
}
pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
self.content
.iter()
.filter_map(|p| match p {
ContentPart::ToolCall(tc) => Some(tc),
_ => None,
})
.collect()
}
pub fn has_tool_calls(&self) -> bool {
self.content
.iter()
.any(|p| matches!(p, ContentPart::ToolCall(_)))
}
pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
self.content.iter().find_map(|p| match p {
ContentPart::ToolResult(tr) => Some(tr),
_ => None,
})
}
pub fn content_to_llm_string(&self) -> String {
self.content
.iter()
.map(|part| match part {
ContentPart::Text(t) => t.text.clone(),
ContentPart::Reasoning(_) => String::new(),
ContentPart::Image(_) => "[Image]".to_string(),
ContentPart::ImageFile(_) => "[Image File]".to_string(),
ContentPart::File(part) => part
.filename
.clone()
.map(|n| format!("[PDF File: {}]", n))
.unwrap_or_else(|| "[PDF File]".to_string()),
ContentPart::ToolCall(tc) => {
format!(
"Tool call: {} with arguments: {}",
tc.name,
serde_json::to_string(&tc.arguments).unwrap_or_default()
)
}
ContentPart::ToolResult(tr) => {
if let Some(err) = &tr.error {
format!("Tool error: {}", err)
} else if let Some(res) = &tr.result {
serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
} else {
"{}".to_string()
}
}
})
.filter(|rendered| !rendered.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub fn to_openai_format(&self) -> serde_json::Value {
let role = match self.role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Agent => "assistant",
MessageRole::ToolResult => "tool",
};
if self.role == MessageRole::ToolResult {
let tool_call_id = self.tool_call_id().unwrap_or("");
let content = self
.content
.iter()
.find_map(|p| match p {
ContentPart::ToolResult(tr) => {
if let Some(error) = &tr.error {
Some(format!("Error: {}", error))
} else if let Some(result) = &tr.result {
Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
} else {
Some("{}".to_string())
}
}
_ => None,
})
.unwrap_or_else(|| "{}".to_string());
return serde_json::json!({
"role": role,
"content": content,
"tool_call_id": tool_call_id
});
}
if self.role == MessageRole::Agent {
let tool_calls: Vec<serde_json::Value> = self
.content
.iter()
.filter_map(|p| match p {
ContentPart::ToolCall(tc) => Some(serde_json::json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
}
})),
_ => None,
})
.collect();
let text_content: String = self
.content
.iter()
.filter_map(|p| match p {
ContentPart::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if tool_calls.is_empty() {
return serde_json::json!({
"role": role,
"content": text_content
});
} else {
let mut result = serde_json::json!({
"role": role,
"tool_calls": tool_calls
});
if !text_content.is_empty() {
result["content"] = serde_json::json!(text_content);
}
return result;
}
}
let content = self.content_to_openai_format();
serde_json::json!({
"role": role,
"content": content
})
}
fn content_to_openai_format(&self) -> serde_json::Value {
if self.content.len() == 1
&& let ContentPart::Text(t) = &self.content[0]
{
return serde_json::json!(t.text);
}
let parts: Vec<serde_json::Value> = self
.content
.iter()
.filter_map(|part| part.to_openai_format())
.collect();
if parts.is_empty() {
return serde_json::json!("");
}
if parts.len() == 1
&& let Some(text) = parts[0].get("text")
{
return text.clone();
}
serde_json::json!(parts)
}
}
pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
let mut result = Vec::new();
for (i, msg) in messages.iter().enumerate() {
result.push(msg.clone());
if msg.role == MessageRole::Agent && msg.has_tool_calls() {
for tc in msg.tool_calls() {
let has_result = messages[(i + 1)..]
.iter()
.any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
if !has_result {
result.push(Message::tool_result(
&tc.id,
None,
Some(
"cancelled - another message came in before it could be completed"
.to_string(),
),
));
}
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool_types::ToolCall;
use serde_json::json;
fn calls() -> Vec<ToolCall> {
vec![
ToolCall {
id: "call_search".into(),
name: "search".into(),
arguments: json!({"q": "rust"}),
},
ToolCall {
id: "call_fetch".into(),
name: "fetch".into(),
arguments: json!({"url": "https://example.com"}),
},
]
}
fn assert_messages(actual: &[Message], expected: &[Message]) {
assert_eq!(
serde_json::to_value(actual).unwrap(),
serde_json::to_value(expected).unwrap()
);
}
#[test]
fn native_custom_call_survives_transcript_serialization_and_conversion() {
let native = everruns_provider::native_async::NativeToolCall::Custom {
call_id: "original-call".into(),
name: "lookup".into(),
input: "raw\nquery: \"value\"".into(),
asynchronous: true,
};
let mut message = Message::assistant("");
message.content.push(ContentPart::ToolCall(
ToolCallContentPart::from_native(native.clone()).unwrap(),
));
let restored: Message =
serde_json::from_slice(&serde_json::to_vec(&message).unwrap()).unwrap();
assert_eq!(restored.tool_calls()[0].native.as_ref(), Some(&native));
let llm = crate::llm_conversions::llm_message_from_message(&restored);
assert_eq!(llm.native_tool_calls, vec![native]);
assert_eq!(llm.tool_calls.unwrap()[0].id, "original-call");
}
#[test]
fn settled_transcripts_are_preserved_without_synthetic_results() {
for messages in [
vec![],
vec![Message::user("Hello"), Message::assistant("Hi")],
vec![
Message::assistant_with_tools("Searching", vec![calls()[0].clone()]),
Message::tool_result("call_search", Some(json!({"found": 2})), None),
],
] {
assert_messages(&patch_dangling_tool_calls(&messages), &messages);
}
}
#[test]
fn dangling_calls_get_only_missing_cancellations_and_patching_is_idempotent() {
let messages = vec![
Message::user("Search then fetch"),
Message::assistant_with_tools("Working", calls()),
Message::user("Never mind"),
Message::tool_result("call_search", Some(json!({"found": 2})), None),
];
let patched = patch_dangling_tool_calls(&messages);
assert_eq!(patched.len(), 5);
assert_messages(&patched[..2], &messages[..2]);
assert_messages(&patched[3..], &messages[2..]);
assert_eq!(patched[2].role, MessageRole::ToolResult);
assert_eq!(
serde_json::to_value(&patched[2].content).unwrap(),
json!([{
"type": "tool_result", "tool_call_id": "call_fetch",
"error": "cancelled - another message came in before it could be completed"
}])
);
assert_messages(&patch_dangling_tool_calls(&patched), &patched);
}
#[test]
fn plain_message_constructors_preserve_role_and_text() {
for (message, role, text) in [
(Message::user("question"), MessageRole::User, "question"),
(Message::assistant("answer"), MessageRole::Agent, "answer"),
(
Message::system("instruction"),
MessageRole::System,
"instruction",
),
] {
assert_eq!(message.role, role);
assert_eq!(message.text(), Some(text));
assert_eq!(message.content, vec![ContentPart::text(text)]);
assert!(!message.has_tool_calls());
}
}
#[test]
fn tool_result_constructor_preserves_result_and_error_fields() {
for (result, error) in [
(Some(json!({"count": 2})), None),
(None, Some("timeout".to_owned())),
(Some(json!(false)), Some("partial".to_owned())),
] {
let message = Message::tool_result("call_result", result.clone(), error.clone());
assert_eq!(message.role, MessageRole::ToolResult);
assert_eq!(message.tool_call_id(), Some("call_result"));
assert_eq!(
message.content,
vec![ContentPart::tool_result("call_result", result, error)]
);
}
}
#[test]
fn assistant_tool_messages_preserve_calls_and_distinguish_empty_from_whitespace_text() {
for text in ["", " ", "Working"] {
let message = Message::assistant_with_tools(text, calls());
let tool_parts: Vec<_> = calls()
.into_iter()
.map(|c| ContentPart::tool_call(c.id, c.name, c.arguments))
.collect();
let mut expected = vec![];
if !text.is_empty() {
expected.push(ContentPart::text(text));
}
expected.extend(tool_parts);
assert_eq!(message.role, MessageRole::Agent);
assert_eq!(message.text(), (!text.is_empty()).then_some(text));
assert_eq!(message.content, expected);
assert!(message.has_tool_calls());
assert_eq!(
serde_json::to_value(message.tool_calls()).unwrap(),
serde_json::to_value(calls()).unwrap()
);
}
}
#[test]
fn openai_plain_messages_map_internal_roles_and_preserve_text() {
for (message, expected) in [
(
Message::user("question"),
json!({"role": "user", "content": "question"}),
),
(
Message::system("instruction"),
json!({"role": "system", "content": "instruction"}),
),
(
Message::assistant("answer"),
json!({"role": "assistant", "content": "answer"}),
),
] {
assert_eq!(message.to_openai_format(), expected);
}
}
#[test]
fn openai_tool_calls_preserve_ids_arguments_and_optional_text() {
for text in ["", "Working"] {
let message = Message::assistant_with_tools(text, calls());
let mut expected = json!({"role": "assistant", "tool_calls": [
{"id": "call_search", "type": "function", "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}},
{"id": "call_fetch", "type": "function", "function": {"name": "fetch", "arguments": "{\"url\":\"https://example.com\"}"}}
]});
if !text.is_empty() {
expected["content"] = text.into();
}
assert_eq!(message.to_openai_format(), expected);
}
}
#[test]
fn openai_tool_results_prefer_errors_and_preserve_call_identity() {
for (result, error, content) in [
(
Some(json!({"temperature":72})),
None,
"{\"temperature\":72}",
),
(None, Some("timeout"), "Error: timeout"),
(
Some(json!({"partial":true})),
Some("partial failure"),
"Error: partial failure",
),
(None, None, "{}"),
] {
let message = Message::tool_result("call_result", result, error.map(str::to_owned));
assert_eq!(
message.to_openai_format(),
json!({"role":"tool", "tool_call_id":"call_result", "content":content})
);
}
}
#[test]
fn openai_content_parts_preserve_text_and_image_sources() {
for (part, expected) in [
(
ContentPart::text("Hello"),
json!({"type":"text", "text":"Hello"}),
),
(
ContentPart::image_url("https://example.com/img.png"),
json!({"type":"image_url", "image_url":{"url":"https://example.com/img.png"}}),
),
(
ContentPart::Image(ImageContentPart::from_base64("YWJj", "image/jpeg")),
json!({"type":"image_url", "image_url":{"url":"data:image/jpeg;base64,YWJj"}}),
),
(
ContentPart::Image(ImageContentPart {
url: None,
base64: Some("YWJj".into()),
media_type: None,
}),
json!({"type":"image_url", "image_url":{"url":"data:image/png;base64,YWJj"}}),
),
(
ContentPart::Image(ImageContentPart {
url: Some("https://example.com/preferred".into()),
base64: Some("YWJj".into()),
media_type: Some("image/jpeg".into()),
}),
json!({"type":"image_url", "image_url":{"url":"https://example.com/preferred"}}),
),
] {
assert_eq!(part.to_openai_format(), Some(expected));
}
assert!(
ContentPart::Image(ImageContentPart {
url: None,
base64: None,
media_type: None
})
.to_openai_format()
.is_none()
);
}
#[test]
fn openai_content_parts_exclude_tool_file_and_reasoning_artifacts() {
for part in [
ContentPart::tool_call("call_1", "lookup", json!({})),
ContentPart::tool_result("call_1", Some(json!(42)), None),
ContentPart::image_file(ImageId::new()),
ContentPart::reasoning(
ReasoningContentPart::opaque("test").with_signature("private-signature"),
),
] {
assert!(part.to_openai_format().is_none());
}
}
#[test]
fn openai_message_content_preserves_multimodal_order_and_filters_unsupported_parts() {
let mut message = Message::user("before");
message
.content
.push(ContentPart::image_url("https://example.com/image"));
message.content.push(ContentPart::text("after"));
assert_eq!(
message.to_openai_format(),
json!({"role":"user", "content":[
{"type":"text", "text":"before"}, {"type":"image_url", "image_url":{"url":"https://example.com/image"}},
{"type":"text", "text":"after"}
]})
);
message.content = vec![
ContentPart::tool_call("ignored", "tool", json!({})),
ContentPart::text("kept"),
];
assert_eq!(
message.to_openai_format(),
json!({"role":"user", "content":"kept"})
);
message.content.remove(1);
assert_eq!(
message.to_openai_format(),
json!({"role":"user", "content":""})
);
let mut assistant = Message::assistant("first");
assistant.content.push(ContentPart::text("second"));
assert_eq!(
assistant.to_openai_format(),
json!({"role":"assistant", "content":"first\nsecond"})
);
}
#[test]
fn message_phase_wire_contract_preserves_optional_source() {
for (phase, wire) in [
(None, None),
(Some(ExecutionPhase::Commentary), Some("commentary")),
(Some(ExecutionPhase::FinalAnswer), Some("final_answer")),
] {
for source in [
None,
Some(PhaseSource::Provider),
Some(PhaseSource::Derived),
] {
if phase.is_none() && source.is_some() {
continue;
}
let message = match (phase, source) {
(Some(phase), Some(source)) => {
Message::assistant("answer").with_phase_from(phase, source)
}
(Some(phase), None) => Message::assistant("answer").with_phase(phase),
_ => Message::assistant("answer"),
};
let json = serde_json::to_value(&message).unwrap();
assert_eq!(
json.get("phase"),
wire.map(serde_json::Value::from).as_ref()
);
let source_wire = match source {
Some(PhaseSource::Provider) => Some("provider"),
Some(PhaseSource::Derived) => Some("derived"),
None => None,
};
assert_eq!(
json.get("phase_source"),
source_wire.map(serde_json::Value::from).as_ref()
);
let decoded: Message = serde_json::from_value(json.clone()).unwrap();
assert_eq!(decoded.phase, phase);
assert_eq!(decoded.phase_source, source);
assert_eq!(decoded.text(), Some("answer"));
assert_eq!(serde_json::to_value(decoded).unwrap(), json);
}
}
}
#[test]
fn hints_merge_shallowly_with_message_precedence() {
let session = std::collections::HashMap::from([
("shared".into(), json!({"old":1})),
("session_only".into(), json!(42)),
]);
let message = std::collections::HashMap::from([
("shared".into(), json!({"new":2})),
("message_only".into(), json!(null)),
]);
for (left, right, expected) in [
(None, None, json!({})),
(
Some(&session),
None,
json!({"shared":{"old":1},"session_only":42}),
),
(
None,
Some(&message),
json!({"shared":{"new":2},"message_only":null}),
),
(
Some(&session),
Some(&message),
json!({"shared":{"new":2},"session_only":42,"message_only":null}),
),
] {
assert_eq!(
serde_json::to_value(Controls::resolve_hints(left, right)).unwrap(),
expected
);
}
}
#[test]
fn controls_wire_contract_preserves_all_overrides_and_legacy_defaults() {
let expected = json!({"model_id":"model_00000000000000000000000000000006", "locale":"uk-UA",
"reasoning":{"effort":"high"}, "speed":"priority", "verbosity":"low", "error_disclosure":"generic",
"hints":{"setup_connection":true,"theme":"dark"}});
let controls = Controls {
model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(6))),
locale: Some("uk-UA".into()),
reasoning: Some(ReasoningConfig {
effort: Some(everruns_provider::model::ReasoningEffort::High),
}),
speed: Some("priority".into()),
verbosity: Some("low".into()),
error_disclosure: Some("generic".into()),
hints: Some(std::collections::HashMap::from([
("setup_connection".into(), json!(true)),
("theme".into(), json!("dark")),
])),
};
assert_eq!(serde_json::to_value(&controls).unwrap(), expected);
assert_eq!(
serde_json::from_value::<Controls>(expected).unwrap(),
controls
);
let legacy: Controls = serde_json::from_value(json!({})).unwrap();
assert_eq!(serde_json::to_value(legacy).unwrap(), json!({}));
}
#[test]
fn tool_result_text_preserves_strings_without_json_escaping() {
let value = serde_json::json!("{\n \"count\": 1\n}");
assert_eq!(
ContentPart::tool_result_text(&value).as_text(),
Some("{\n \"count\": 1\n}")
);
}
#[test]
fn tool_result_text_serializes_structured_values() {
for (value, expected) in [
(json!({"count":1}), "{\"count\":1}"),
(json!([true, 2]), "[true,2]"),
(json!(null), "null"),
] {
assert_eq!(
ContentPart::tool_result_text(&value).as_text(),
Some(expected)
);
}
}
#[test]
fn file_content_part_serde_roundtrip() {
let part = ContentPart::File(FileContentPart::with_filename(FileId::new(), "report.pdf"));
let v = serde_json::to_value(&part).unwrap();
assert_eq!(v["type"], serde_json::json!("file"));
assert_eq!(v["filename"], serde_json::json!("report.pdf"));
let back: ContentPart = serde_json::from_value(v).unwrap();
assert_eq!(back, part);
assert!(back.is_file());
assert_eq!(back.content_type(), ContentType::File);
assert_eq!(ContentType::File.to_string(), "file");
}
}