1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::typed_id::{FileId, ImageId, MessageId, ModelId};
13
14#[cfg(feature = "openapi")]
15use utoipa::ToSchema;
16
17use everruns_provider::execution_phase::{ExecutionPhase, PhaseSource};
18use everruns_provider::reasoning::ReasoningContentPart;
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(ToSchema))]
22#[cfg_attr(feature = "openapi", schema(as = RuntimeMessageRole))]
26#[serde(rename_all = "snake_case")]
27pub enum MessageRole {
28 System,
30 User,
32 Agent,
34 ToolResult,
36}
37
38impl std::fmt::Display for MessageRole {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 MessageRole::System => write!(f, "system"),
42 MessageRole::User => write!(f, "user"),
43 MessageRole::Agent => write!(f, "agent"),
44 MessageRole::ToolResult => write!(f, "tool_result"),
45 }
46 }
47}
48
49impl From<&str> for MessageRole {
50 fn from(s: &str) -> Self {
51 match s.to_lowercase().as_str() {
52 "system" => MessageRole::System,
53 "user" => MessageRole::User,
54 "agent" | "assistant" => MessageRole::Agent,
56 "tool_result" => MessageRole::ToolResult,
57 _ => MessageRole::User,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[cfg_attr(feature = "openapi", derive(ToSchema))]
74pub struct ExternalActor {
75 pub actor_id: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub actor_name: Option<String>,
80 pub source: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub metadata: Option<std::collections::HashMap<String, String>>,
85}
86
87impl ExternalActor {
88 pub fn display_label(&self) -> &str {
90 self.actor_name.as_deref().unwrap_or(&self.actor_id)
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101pub struct ReasoningConfig {
102 #[serde(skip_serializing_if = "Option::is_none")]
108 pub effort: Option<everruns_provider::model::ReasoningEffort>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
113#[cfg_attr(feature = "openapi", derive(ToSchema))]
114pub struct Controls {
115 #[serde(skip_serializing_if = "Option::is_none")]
118 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
119 pub model_id: Option<ModelId>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
124 pub locale: Option<String>,
125
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub reasoning: Option<ReasoningConfig>,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
134 pub speed: Option<String>,
135
136 #[serde(skip_serializing_if = "Option::is_none")]
140 pub verbosity: Option<String>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
147 pub error_disclosure: Option<String>,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
155 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
156 pub hints: Option<std::collections::HashMap<String, serde_json::Value>>,
157}
158
159impl Controls {
160 pub fn resolve_hints(
163 session_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
164 message_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
165 ) -> std::collections::HashMap<String, serde_json::Value> {
166 match (session_hints, message_hints) {
167 (None, None) => std::collections::HashMap::new(),
168 (Some(s), None) => s.clone(),
169 (None, Some(m)) => m.clone(),
170 (Some(s), Some(m)) => {
171 let mut merged = s.clone();
172 merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
173 merged
174 }
175 }
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(ToSchema))]
182#[cfg_attr(feature = "openapi", schema(as = RuntimeMessage))]
187pub struct Message {
188 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
190 pub id: MessageId,
191
192 pub role: MessageRole,
194
195 pub content: Vec<ContentPart>,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub phase: Option<ExecutionPhase>,
206
207 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub phase_source: Option<PhaseSource>,
213
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub controls: Option<Controls>,
217
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
221 pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
222
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub external_actor: Option<ExternalActor>,
226
227 pub created_at: DateTime<Utc>,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[cfg_attr(feature = "openapi", derive(ToSchema))]
238#[serde(rename_all = "snake_case")]
239pub enum ContentType {
240 Text,
241 Image,
242 ImageFile,
243 File,
245 ToolCall,
246 ToolResult,
247 Reasoning,
248}
249
250impl std::fmt::Display for ContentType {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 match self {
253 ContentType::Text => write!(f, "text"),
254 ContentType::Image => write!(f, "image"),
255 ContentType::ImageFile => write!(f, "image_file"),
256 ContentType::File => write!(f, "file"),
257 ContentType::ToolCall => write!(f, "tool_call"),
258 ContentType::ToolResult => write!(f, "tool_result"),
259 ContentType::Reasoning => write!(f, "reasoning"),
260 }
261 }
262}
263
264impl From<&str> for ContentType {
265 fn from(s: &str) -> Self {
266 match s {
267 "image" => ContentType::Image,
268 "image_file" => ContentType::ImageFile,
269 "tool_call" => ContentType::ToolCall,
270 "tool_result" => ContentType::ToolResult,
271 "reasoning" => ContentType::Reasoning,
272 _ => ContentType::Text,
273 }
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283#[cfg_attr(feature = "openapi", derive(ToSchema))]
284pub struct TextContentPart {
285 pub text: String,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
292 pub annotations: Vec<TextAnnotation>,
293}
294
295impl TextContentPart {
296 pub fn new(text: impl Into<String>) -> Self {
297 Self {
298 text: text.into(),
299 annotations: Vec::new(),
300 }
301 }
302
303 pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
305 self.annotations = annotations;
306 self
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
317#[cfg_attr(feature = "openapi", derive(ToSchema))]
318pub struct TextAnnotation {
319 #[cfg_attr(feature = "openapi", schema(example = 0))]
321 pub start: usize,
322 #[cfg_attr(feature = "openapi", schema(example = 19))]
324 pub end: usize,
325 #[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
328 pub origin: String,
329 pub source: AnnotationSource,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
334 #[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
335 pub external_id: Option<String>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub verified: Option<VerificationVerdict>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
344#[cfg_attr(feature = "openapi", derive(ToSchema))]
345pub struct AnnotationSource {
346 #[cfg_attr(
349 feature = "openapi",
350 schema(example = "github://owner/repo@main/docs/x.md")
351 )]
352 pub uri: String,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 #[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
356 pub title: Option<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
360 #[cfg_attr(
361 feature = "openapi",
362 schema(example = "The control plane owns durable state.")
363 )]
364 pub snippet: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub location: Option<serde_json::Value>,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
373#[cfg_attr(feature = "openapi", derive(ToSchema))]
374pub struct VerificationVerdict {
375 pub status: VerificationStatus,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 #[cfg_attr(feature = "openapi", schema(example = 0.92))]
380 pub score: Option<f32>,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385#[cfg_attr(feature = "openapi", derive(ToSchema))]
386#[cfg_attr(feature = "openapi", schema(example = "entailed"))]
387#[serde(rename_all = "snake_case")]
388pub enum VerificationStatus {
389 Entailed,
391 Unsupported,
393 Uncertain,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
399#[cfg_attr(feature = "openapi", derive(ToSchema))]
400pub struct ImageContentPart {
401 #[serde(skip_serializing_if = "Option::is_none")]
402 pub url: Option<String>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub base64: Option<String>,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 pub media_type: Option<String>,
407}
408
409impl ImageContentPart {
410 pub fn from_url(url: impl Into<String>) -> Self {
411 Self {
412 url: Some(url.into()),
413 base64: None,
414 media_type: None,
415 }
416 }
417
418 pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
419 Self {
420 url: None,
421 base64: Some(base64.into()),
422 media_type: Some(media_type.into()),
423 }
424 }
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
433#[cfg_attr(feature = "openapi", derive(ToSchema))]
434pub struct ImageFileContentPart {
435 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
437 pub image_id: ImageId,
438 #[serde(skip_serializing_if = "Option::is_none")]
440 pub filename: Option<String>,
441}
442
443impl ImageFileContentPart {
444 pub fn new(image_id: ImageId) -> Self {
445 Self {
446 image_id,
447 filename: None,
448 }
449 }
450
451 pub fn with_filename(image_id: ImageId, filename: impl Into<String>) -> Self {
452 Self {
453 image_id,
454 filename: Some(filename.into()),
455 }
456 }
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
464#[cfg_attr(feature = "openapi", derive(ToSchema))]
465pub struct FileContentPart {
466 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "file_01933b5a00007000800000000000001"))]
468 pub file_id: FileId,
469 #[serde(skip_serializing_if = "Option::is_none")]
471 pub filename: Option<String>,
472}
473
474impl FileContentPart {
475 pub fn new(file_id: FileId) -> Self {
477 Self {
478 file_id,
479 filename: None,
480 }
481 }
482
483 pub fn with_filename(file_id: FileId, filename: impl Into<String>) -> Self {
485 Self {
486 file_id,
487 filename: Some(filename.into()),
488 }
489 }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
494#[cfg_attr(feature = "openapi", derive(ToSchema))]
495pub struct ToolCallContentPart {
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub native: Option<everruns_provider::native_async::NativeToolCall>,
499 pub id: String,
500 pub name: String,
501 pub arguments: serde_json::Value,
502}
503
504impl ToolCallContentPart {
505 pub fn from_native(
507 call: everruns_provider::native_async::NativeToolCall,
508 ) -> crate::error::Result<Self> {
509 use everruns_provider::native_async::NativeToolCall;
510 call.validate()?;
511 let arguments = match &call {
512 NativeToolCall::Function { arguments, .. } => serde_json::from_str(arguments)
513 .map_err(|error| crate::error::AgentLoopError::llm(error.to_string()))?,
514 NativeToolCall::Custom { input, .. } => serde_json::Value::String(input.clone()),
515 };
516 Ok(Self {
517 id: call.id().into(),
518 name: call.name().into(),
519 arguments,
520 native: Some(call),
521 })
522 }
523
524 pub fn new(
525 id: impl Into<String>,
526 name: impl Into<String>,
527 arguments: serde_json::Value,
528 ) -> Self {
529 Self {
530 native: None,
531 id: id.into(),
532 name: name.into(),
533 arguments,
534 }
535 }
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
540#[cfg_attr(feature = "openapi", derive(ToSchema))]
541pub struct ToolResultContentPart {
542 pub tool_call_id: String,
544 #[serde(skip_serializing_if = "Option::is_none")]
545 pub result: Option<serde_json::Value>,
546 #[serde(skip_serializing_if = "Option::is_none")]
547 pub error: Option<String>,
548}
549
550impl ToolResultContentPart {
551 pub fn new(
552 tool_call_id: impl Into<String>,
553 result: Option<serde_json::Value>,
554 error: Option<String>,
555 ) -> Self {
556 Self {
557 tool_call_id: tool_call_id.into(),
558 result,
559 error,
560 }
561 }
562
563 pub fn success(tool_call_id: impl Into<String>, result: serde_json::Value) -> Self {
564 Self {
565 tool_call_id: tool_call_id.into(),
566 result: Some(result),
567 error: None,
568 }
569 }
570
571 pub fn error(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
572 Self {
573 tool_call_id: tool_call_id.into(),
574 result: None,
575 error: Some(error.into()),
576 }
577 }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
589#[cfg_attr(feature = "openapi", derive(ToSchema))]
590#[serde(tag = "type", rename_all = "snake_case")]
591#[non_exhaustive]
592pub enum ContentPart {
593 Text(TextContentPart),
595 Image(ImageContentPart),
597 ImageFile(ImageFileContentPart),
599 File(FileContentPart),
601 ToolCall(ToolCallContentPart),
603 ToolResult(ToolResultContentPart),
605 Reasoning(ReasoningContentPart),
608}
609
610impl ContentPart {
611 pub fn text(text: impl Into<String>) -> Self {
613 ContentPart::Text(TextContentPart::new(text))
614 }
615
616 pub fn tool_result_text(value: &serde_json::Value) -> Self {
619 match value {
620 serde_json::Value::String(text) => Self::text(text.clone()),
621 other => Self::text(other.to_string()),
622 }
623 }
624
625 pub fn image_url(url: impl Into<String>) -> Self {
627 ContentPart::Image(ImageContentPart::from_url(url))
628 }
629
630 pub fn image_file(image_id: ImageId) -> Self {
632 ContentPart::ImageFile(ImageFileContentPart::new(image_id))
633 }
634
635 pub fn file(file_id: FileId) -> Self {
637 ContentPart::File(FileContentPart::new(file_id))
638 }
639
640 pub fn tool_call(
642 id: impl Into<String>,
643 name: impl Into<String>,
644 arguments: serde_json::Value,
645 ) -> Self {
646 ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
647 }
648
649 pub fn tool_result(
651 tool_call_id: impl Into<String>,
652 result: Option<serde_json::Value>,
653 error: Option<String>,
654 ) -> Self {
655 ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
656 }
657
658 pub fn reasoning(part: ReasoningContentPart) -> Self {
660 ContentPart::Reasoning(part)
661 }
662
663 pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
665 match self {
666 ContentPart::Reasoning(r) => Some(r),
667 _ => None,
668 }
669 }
670
671 pub fn is_reasoning(&self) -> bool {
673 matches!(self, ContentPart::Reasoning(_))
674 }
675
676 pub fn as_text(&self) -> Option<&str> {
678 match self {
679 ContentPart::Text(t) => Some(&t.text),
680 _ => None,
681 }
682 }
683
684 pub fn is_image_file(&self) -> bool {
686 matches!(self, ContentPart::ImageFile(_))
687 }
688
689 pub fn is_file(&self) -> bool {
691 matches!(self, ContentPart::File(_))
692 }
693
694 pub fn content_type(&self) -> ContentType {
696 match self {
697 ContentPart::Text(_) => ContentType::Text,
698 ContentPart::Image(_) => ContentType::Image,
699 ContentPart::ImageFile(_) => ContentType::ImageFile,
700 ContentPart::File(_) => ContentType::File,
701 ContentPart::ToolCall(_) => ContentType::ToolCall,
702 ContentPart::ToolResult(_) => ContentType::ToolResult,
703 ContentPart::Reasoning(_) => ContentType::Reasoning,
704 }
705 }
706
707 pub fn to_openai_format(&self) -> Option<serde_json::Value> {
712 match self {
713 ContentPart::Text(t) => Some(serde_json::json!({
714 "type": "text",
715 "text": t.text
716 })),
717 ContentPart::Image(img) => {
718 if let Some(url) = &img.url {
719 Some(serde_json::json!({
720 "type": "image_url",
721 "image_url": { "url": url }
722 }))
723 } else if let Some(b64) = &img.base64 {
724 let media_type = img.media_type.as_deref().unwrap_or("image/png");
725 Some(serde_json::json!({
726 "type": "image_url",
727 "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
728 }))
729 } else {
730 None
731 }
732 }
733 _ => None,
735 }
736 }
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
744#[cfg_attr(feature = "openapi", derive(ToSchema))]
745#[serde(tag = "type", rename_all = "snake_case")]
746pub enum InputContentPart {
747 Text(TextContentPart),
749 Image(ImageContentPart),
751 ImageFile(ImageFileContentPart),
753 File(FileContentPart),
755}
756
757impl From<InputContentPart> for ContentPart {
758 fn from(input: InputContentPart) -> Self {
759 match input {
760 InputContentPart::Text(t) => ContentPart::Text(t),
761 InputContentPart::Image(i) => ContentPart::Image(i),
762 InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
763 InputContentPart::File(f) => ContentPart::File(f),
764 }
765 }
766}
767
768impl InputContentPart {
769 pub fn text(text: impl Into<String>) -> Self {
771 InputContentPart::Text(TextContentPart::new(text))
772 }
773
774 pub fn image_url(url: impl Into<String>) -> Self {
776 InputContentPart::Image(ImageContentPart::from_url(url))
777 }
778
779 pub fn image_file(image_id: ImageId) -> Self {
781 InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
782 }
783
784 pub fn file(file_id: FileId) -> Self {
786 InputContentPart::File(FileContentPart::new(file_id))
787 }
788
789 pub fn as_text(&self) -> Option<&str> {
791 match self {
792 InputContentPart::Text(t) => Some(&t.text),
793 _ => None,
794 }
795 }
796
797 pub fn content_type(&self) -> ContentType {
799 match self {
800 InputContentPart::Text(_) => ContentType::Text,
801 InputContentPart::Image(_) => ContentType::Image,
802 InputContentPart::ImageFile(_) => ContentType::ImageFile,
803 InputContentPart::File(_) => ContentType::File,
804 }
805 }
806}
807
808impl Message {
809 pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
811 self.content.iter().filter_map(ContentPart::as_reasoning)
812 }
813
814 pub fn has_reasoning(&self) -> bool {
816 self.content.iter().any(ContentPart::is_reasoning)
817 }
818
819 pub fn reasoning_display_text(&self) -> Option<String> {
824 let joined = self
825 .reasoning_parts()
826 .filter_map(ReasoningContentPart::display_text)
827 .collect::<Vec<_>>()
828 .join("\n\n");
829 (!joined.is_empty()).then_some(joined)
830 }
831
832 pub fn into_public(mut self) -> Self {
835 for part in &mut self.content {
836 if let ContentPart::Reasoning(r) = part {
837 *r = r.to_public();
838 }
839 }
840 self
841 }
842
843 pub fn with_id(mut self, id: MessageId) -> Self {
848 self.id = id;
849 self
850 }
851
852 pub fn user(content: impl Into<String>) -> Self {
854 Self {
855 id: MessageId::new(),
856 role: MessageRole::User,
857 content: vec![ContentPart::text(content)],
858 phase: None,
859 phase_source: None,
860 controls: None,
861 metadata: None,
862 external_actor: None,
863 created_at: Utc::now(),
864 }
865 }
866
867 pub fn assistant(content: impl Into<String>) -> Self {
869 Self {
870 id: MessageId::new(),
871 role: MessageRole::Agent,
872 content: vec![ContentPart::text(content)],
873 phase: None,
874 phase_source: None,
875 controls: None,
876 metadata: None,
877 external_actor: None,
878 created_at: Utc::now(),
879 }
880 }
881
882 pub fn assistant_with_tools(
888 content: impl Into<String>,
889 tool_calls: Vec<crate::tool_types::ToolCall>,
890 ) -> Self {
891 let text_content = content.into();
892 let mut parts = Vec::new();
893 if !text_content.is_empty() {
895 parts.push(ContentPart::text(text_content));
896 }
897 for tc in tool_calls {
898 parts.push(ContentPart::ToolCall(ToolCallContentPart {
899 native: None,
900 id: tc.id,
901 name: tc.name,
902 arguments: tc.arguments,
903 }));
904 }
905 Self {
906 id: MessageId::new(),
907 role: MessageRole::Agent,
908 content: parts,
909 phase: None,
910 phase_source: None,
911 controls: None,
912 metadata: None,
913 external_actor: None,
914 created_at: Utc::now(),
915 }
916 }
917
918 pub fn system(content: impl Into<String>) -> Self {
920 Self {
921 id: MessageId::new(),
922 role: MessageRole::System,
923 content: vec![ContentPart::text(content)],
924 phase: None,
925 phase_source: None,
926 controls: None,
927 metadata: None,
928 external_actor: None,
929 created_at: Utc::now(),
930 }
931 }
932
933 pub fn tool_result(
935 tool_call_id: impl Into<String>,
936 result: Option<serde_json::Value>,
937 error: Option<String>,
938 ) -> Self {
939 let tool_call_id = tool_call_id.into();
940 Self {
941 id: MessageId::new(),
942 role: MessageRole::ToolResult,
943 content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
944 tool_call_id,
945 result,
946 error,
947 ))],
948 phase: None,
949 phase_source: None,
950 controls: None,
951 metadata: None,
952 external_actor: None,
953 created_at: Utc::now(),
954 }
955 }
956
957 pub fn tool_result_with_images(
963 tool_call_id: impl Into<String>,
964 result: Option<serde_json::Value>,
965 images: Vec<everruns_provider::tool_types::ToolResultImage>,
966 ) -> Self {
967 let tool_call_id = tool_call_id.into();
968 let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
969 tool_call_id,
970 result,
971 None,
972 ))];
973 for img in images {
974 content.push(ContentPart::Image(ImageContentPart::from_base64(
975 img.base64,
976 img.media_type,
977 )));
978 }
979 Self {
980 id: MessageId::new(),
981 role: MessageRole::ToolResult,
982 content,
983 phase: None,
984 phase_source: None,
985 controls: None,
986 metadata: None,
987 external_actor: None,
988 created_at: Utc::now(),
989 }
990 }
991
992 pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
994 self.phase = Some(phase);
995 self
996 }
997
998 pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
1000 self.phase = Some(phase);
1001 self.phase_source = Some(source);
1002 self
1003 }
1004
1005 pub fn tool_call_id(&self) -> Option<&str> {
1009 self.content.iter().find_map(|p| match p {
1010 ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
1011 _ => None,
1012 })
1013 }
1014
1015 pub fn text(&self) -> Option<&str> {
1017 self.content.iter().find_map(|p| p.as_text())
1018 }
1019
1020 pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
1022 self.content
1023 .iter()
1024 .filter_map(|p| match p {
1025 ContentPart::ToolCall(tc) => Some(tc),
1026 _ => None,
1027 })
1028 .collect()
1029 }
1030
1031 pub fn has_tool_calls(&self) -> bool {
1033 self.content
1034 .iter()
1035 .any(|p| matches!(p, ContentPart::ToolCall(_)))
1036 }
1037
1038 pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
1040 self.content.iter().find_map(|p| match p {
1041 ContentPart::ToolResult(tr) => Some(tr),
1042 _ => None,
1043 })
1044 }
1045
1046 pub fn content_to_llm_string(&self) -> String {
1048 self.content
1049 .iter()
1050 .map(|part| match part {
1051 ContentPart::Text(t) => t.text.clone(),
1052 ContentPart::Reasoning(_) => String::new(),
1056 ContentPart::Image(_) => "[Image]".to_string(),
1057 ContentPart::ImageFile(_) => "[Image File]".to_string(),
1058 ContentPart::File(part) => part
1059 .filename
1060 .clone()
1061 .map(|n| format!("[PDF File: {}]", n))
1062 .unwrap_or_else(|| "[PDF File]".to_string()),
1063 ContentPart::ToolCall(tc) => {
1064 format!(
1065 "Tool call: {} with arguments: {}",
1066 tc.name,
1067 serde_json::to_string(&tc.arguments).unwrap_or_default()
1068 )
1069 }
1070 ContentPart::ToolResult(tr) => {
1071 if let Some(err) = &tr.error {
1072 format!("Tool error: {}", err)
1073 } else if let Some(res) = &tr.result {
1074 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1075 } else {
1076 "{}".to_string()
1077 }
1078 }
1079 })
1080 .filter(|rendered| !rendered.is_empty())
1081 .collect::<Vec<_>>()
1082 .join("\n")
1083 }
1084
1085 pub fn to_openai_format(&self) -> serde_json::Value {
1094 let role = match self.role {
1095 MessageRole::System => "system",
1096 MessageRole::User => "user",
1097 MessageRole::Agent => "assistant",
1098 MessageRole::ToolResult => "tool",
1099 };
1100
1101 if self.role == MessageRole::ToolResult {
1103 let tool_call_id = self.tool_call_id().unwrap_or("");
1104 let content = self
1105 .content
1106 .iter()
1107 .find_map(|p| match p {
1108 ContentPart::ToolResult(tr) => {
1109 if let Some(error) = &tr.error {
1110 Some(format!("Error: {}", error))
1111 } else if let Some(result) = &tr.result {
1112 Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
1113 } else {
1114 Some("{}".to_string())
1115 }
1116 }
1117 _ => None,
1118 })
1119 .unwrap_or_else(|| "{}".to_string());
1120
1121 return serde_json::json!({
1122 "role": role,
1123 "content": content,
1124 "tool_call_id": tool_call_id
1125 });
1126 }
1127
1128 if self.role == MessageRole::Agent {
1130 let tool_calls: Vec<serde_json::Value> = self
1131 .content
1132 .iter()
1133 .filter_map(|p| match p {
1134 ContentPart::ToolCall(tc) => Some(serde_json::json!({
1135 "id": tc.id,
1136 "type": "function",
1137 "function": {
1138 "name": tc.name,
1139 "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
1140 }
1141 })),
1142 _ => None,
1143 })
1144 .collect();
1145
1146 let text_content: String = self
1147 .content
1148 .iter()
1149 .filter_map(|p| match p {
1150 ContentPart::Text(t) => Some(t.text.clone()),
1151 _ => None,
1152 })
1153 .collect::<Vec<_>>()
1154 .join("\n");
1155
1156 if tool_calls.is_empty() {
1157 return serde_json::json!({
1158 "role": role,
1159 "content": text_content
1160 });
1161 } else {
1162 let mut result = serde_json::json!({
1163 "role": role,
1164 "tool_calls": tool_calls
1165 });
1166 if !text_content.is_empty() {
1167 result["content"] = serde_json::json!(text_content);
1168 }
1169 return result;
1170 }
1171 }
1172
1173 let content = self.content_to_openai_format();
1175 serde_json::json!({
1176 "role": role,
1177 "content": content
1178 })
1179 }
1180
1181 fn content_to_openai_format(&self) -> serde_json::Value {
1183 if self.content.len() == 1
1185 && let ContentPart::Text(t) = &self.content[0]
1186 {
1187 return serde_json::json!(t.text);
1188 }
1189
1190 let parts: Vec<serde_json::Value> = self
1192 .content
1193 .iter()
1194 .filter_map(|part| part.to_openai_format())
1195 .collect();
1196
1197 if parts.is_empty() {
1198 return serde_json::json!("");
1199 }
1200
1201 if parts.len() == 1
1203 && let Some(text) = parts[0].get("text")
1204 {
1205 return text.clone();
1206 }
1207
1208 serde_json::json!(parts)
1209 }
1210}
1211
1212pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
1222 let mut result = Vec::new();
1223
1224 for (i, msg) in messages.iter().enumerate() {
1225 result.push(msg.clone());
1226
1227 if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1229 for tc in msg.tool_calls() {
1230 let has_result = messages[(i + 1)..]
1232 .iter()
1233 .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
1234
1235 if !has_result {
1236 result.push(Message::tool_result(
1237 &tc.id,
1238 None,
1239 Some(
1240 "cancelled - another message came in before it could be completed"
1241 .to_string(),
1242 ),
1243 ));
1244 }
1245 }
1246 }
1247 }
1248
1249 result
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255 use crate::tool_types::ToolCall;
1256 use serde_json::json;
1257
1258 fn calls() -> Vec<ToolCall> {
1259 vec![
1260 ToolCall {
1261 id: "call_search".into(),
1262 name: "search".into(),
1263 arguments: json!({"q": "rust"}),
1264 },
1265 ToolCall {
1266 id: "call_fetch".into(),
1267 name: "fetch".into(),
1268 arguments: json!({"url": "https://example.com"}),
1269 },
1270 ]
1271 }
1272
1273 fn assert_messages(actual: &[Message], expected: &[Message]) {
1274 assert_eq!(
1275 serde_json::to_value(actual).unwrap(),
1276 serde_json::to_value(expected).unwrap()
1277 );
1278 }
1279
1280 #[test]
1281 fn native_custom_call_survives_transcript_serialization_and_conversion() {
1282 let native = everruns_provider::native_async::NativeToolCall::Custom {
1283 call_id: "original-call".into(),
1284 name: "lookup".into(),
1285 input: "raw\nquery: \"value\"".into(),
1286 asynchronous: true,
1287 };
1288 let mut message = Message::assistant("");
1289 message.content.push(ContentPart::ToolCall(
1290 ToolCallContentPart::from_native(native.clone()).unwrap(),
1291 ));
1292 let restored: Message =
1293 serde_json::from_slice(&serde_json::to_vec(&message).unwrap()).unwrap();
1294 assert_eq!(restored.tool_calls()[0].native.as_ref(), Some(&native));
1295 let llm = crate::llm_conversions::llm_message_from_message(&restored);
1296 assert_eq!(llm.native_tool_calls, vec![native]);
1297 assert_eq!(llm.tool_calls.unwrap()[0].id, "original-call");
1298 }
1299
1300 #[test]
1301 fn settled_transcripts_are_preserved_without_synthetic_results() {
1302 for messages in [
1303 vec![],
1304 vec![Message::user("Hello"), Message::assistant("Hi")],
1305 vec![
1306 Message::assistant_with_tools("Searching", vec![calls()[0].clone()]),
1307 Message::tool_result("call_search", Some(json!({"found": 2})), None),
1308 ],
1309 ] {
1310 assert_messages(&patch_dangling_tool_calls(&messages), &messages);
1311 }
1312 }
1313
1314 #[test]
1315 fn dangling_calls_get_only_missing_cancellations_and_patching_is_idempotent() {
1316 let messages = vec![
1317 Message::user("Search then fetch"),
1318 Message::assistant_with_tools("Working", calls()),
1319 Message::user("Never mind"),
1320 Message::tool_result("call_search", Some(json!({"found": 2})), None),
1321 ];
1322 let patched = patch_dangling_tool_calls(&messages);
1323 assert_eq!(patched.len(), 5);
1324 assert_messages(&patched[..2], &messages[..2]);
1325 assert_messages(&patched[3..], &messages[2..]);
1326 assert_eq!(patched[2].role, MessageRole::ToolResult);
1327 assert_eq!(
1328 serde_json::to_value(&patched[2].content).unwrap(),
1329 json!([{
1330 "type": "tool_result", "tool_call_id": "call_fetch",
1331 "error": "cancelled - another message came in before it could be completed"
1332 }])
1333 );
1334 assert_messages(&patch_dangling_tool_calls(&patched), &patched);
1335 }
1336
1337 #[test]
1338 fn plain_message_constructors_preserve_role_and_text() {
1339 for (message, role, text) in [
1340 (Message::user("question"), MessageRole::User, "question"),
1341 (Message::assistant("answer"), MessageRole::Agent, "answer"),
1342 (
1343 Message::system("instruction"),
1344 MessageRole::System,
1345 "instruction",
1346 ),
1347 ] {
1348 assert_eq!(message.role, role);
1349 assert_eq!(message.text(), Some(text));
1350 assert_eq!(message.content, vec![ContentPart::text(text)]);
1351 assert!(!message.has_tool_calls());
1352 }
1353 }
1354
1355 #[test]
1356 fn tool_result_constructor_preserves_result_and_error_fields() {
1357 for (result, error) in [
1358 (Some(json!({"count": 2})), None),
1359 (None, Some("timeout".to_owned())),
1360 (Some(json!(false)), Some("partial".to_owned())),
1361 ] {
1362 let message = Message::tool_result("call_result", result.clone(), error.clone());
1363 assert_eq!(message.role, MessageRole::ToolResult);
1364 assert_eq!(message.tool_call_id(), Some("call_result"));
1365 assert_eq!(
1366 message.content,
1367 vec![ContentPart::tool_result("call_result", result, error)]
1368 );
1369 }
1370 }
1371
1372 #[test]
1373 fn assistant_tool_messages_preserve_calls_and_distinguish_empty_from_whitespace_text() {
1374 for text in ["", " ", "Working"] {
1375 let message = Message::assistant_with_tools(text, calls());
1376 let tool_parts: Vec<_> = calls()
1377 .into_iter()
1378 .map(|c| ContentPart::tool_call(c.id, c.name, c.arguments))
1379 .collect();
1380 let mut expected = vec![];
1381 if !text.is_empty() {
1382 expected.push(ContentPart::text(text));
1383 }
1384 expected.extend(tool_parts);
1385 assert_eq!(message.role, MessageRole::Agent);
1386 assert_eq!(message.text(), (!text.is_empty()).then_some(text));
1387 assert_eq!(message.content, expected);
1388 assert!(message.has_tool_calls());
1389 assert_eq!(
1390 serde_json::to_value(message.tool_calls()).unwrap(),
1391 serde_json::to_value(calls()).unwrap()
1392 );
1393 }
1394 }
1395
1396 #[test]
1397 fn openai_plain_messages_map_internal_roles_and_preserve_text() {
1398 for (message, expected) in [
1399 (
1400 Message::user("question"),
1401 json!({"role": "user", "content": "question"}),
1402 ),
1403 (
1404 Message::system("instruction"),
1405 json!({"role": "system", "content": "instruction"}),
1406 ),
1407 (
1408 Message::assistant("answer"),
1409 json!({"role": "assistant", "content": "answer"}),
1410 ),
1411 ] {
1412 assert_eq!(message.to_openai_format(), expected);
1413 }
1414 }
1415
1416 #[test]
1417 fn openai_tool_calls_preserve_ids_arguments_and_optional_text() {
1418 for text in ["", "Working"] {
1419 let message = Message::assistant_with_tools(text, calls());
1420 let mut expected = json!({"role": "assistant", "tool_calls": [
1421 {"id": "call_search", "type": "function", "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}},
1422 {"id": "call_fetch", "type": "function", "function": {"name": "fetch", "arguments": "{\"url\":\"https://example.com\"}"}}
1423 ]});
1424 if !text.is_empty() {
1425 expected["content"] = text.into();
1426 }
1427 assert_eq!(message.to_openai_format(), expected);
1428 }
1429 }
1430
1431 #[test]
1432 fn openai_tool_results_prefer_errors_and_preserve_call_identity() {
1433 for (result, error, content) in [
1434 (
1435 Some(json!({"temperature":72})),
1436 None,
1437 "{\"temperature\":72}",
1438 ),
1439 (None, Some("timeout"), "Error: timeout"),
1440 (
1441 Some(json!({"partial":true})),
1442 Some("partial failure"),
1443 "Error: partial failure",
1444 ),
1445 (None, None, "{}"),
1446 ] {
1447 let message = Message::tool_result("call_result", result, error.map(str::to_owned));
1448 assert_eq!(
1449 message.to_openai_format(),
1450 json!({"role":"tool", "tool_call_id":"call_result", "content":content})
1451 );
1452 }
1453 }
1454
1455 #[test]
1456 fn openai_content_parts_preserve_text_and_image_sources() {
1457 for (part, expected) in [
1458 (
1459 ContentPart::text("Hello"),
1460 json!({"type":"text", "text":"Hello"}),
1461 ),
1462 (
1463 ContentPart::image_url("https://example.com/img.png"),
1464 json!({"type":"image_url", "image_url":{"url":"https://example.com/img.png"}}),
1465 ),
1466 (
1467 ContentPart::Image(ImageContentPart::from_base64("YWJj", "image/jpeg")),
1468 json!({"type":"image_url", "image_url":{"url":"data:image/jpeg;base64,YWJj"}}),
1469 ),
1470 (
1471 ContentPart::Image(ImageContentPart {
1472 url: None,
1473 base64: Some("YWJj".into()),
1474 media_type: None,
1475 }),
1476 json!({"type":"image_url", "image_url":{"url":"data:image/png;base64,YWJj"}}),
1477 ),
1478 (
1479 ContentPart::Image(ImageContentPart {
1480 url: Some("https://example.com/preferred".into()),
1481 base64: Some("YWJj".into()),
1482 media_type: Some("image/jpeg".into()),
1483 }),
1484 json!({"type":"image_url", "image_url":{"url":"https://example.com/preferred"}}),
1485 ),
1486 ] {
1487 assert_eq!(part.to_openai_format(), Some(expected));
1488 }
1489 assert!(
1490 ContentPart::Image(ImageContentPart {
1491 url: None,
1492 base64: None,
1493 media_type: None
1494 })
1495 .to_openai_format()
1496 .is_none()
1497 );
1498 }
1499
1500 #[test]
1501 fn openai_content_parts_exclude_tool_file_and_reasoning_artifacts() {
1502 for part in [
1503 ContentPart::tool_call("call_1", "lookup", json!({})),
1504 ContentPart::tool_result("call_1", Some(json!(42)), None),
1505 ContentPart::image_file(ImageId::new()),
1506 ContentPart::reasoning(
1507 ReasoningContentPart::opaque("test").with_signature("private-signature"),
1508 ),
1509 ] {
1510 assert!(part.to_openai_format().is_none());
1511 }
1512 }
1513
1514 #[test]
1515 fn openai_message_content_preserves_multimodal_order_and_filters_unsupported_parts() {
1516 let mut message = Message::user("before");
1517 message
1518 .content
1519 .push(ContentPart::image_url("https://example.com/image"));
1520 message.content.push(ContentPart::text("after"));
1521 assert_eq!(
1522 message.to_openai_format(),
1523 json!({"role":"user", "content":[
1524 {"type":"text", "text":"before"}, {"type":"image_url", "image_url":{"url":"https://example.com/image"}},
1525 {"type":"text", "text":"after"}
1526 ]})
1527 );
1528 message.content = vec![
1529 ContentPart::tool_call("ignored", "tool", json!({})),
1530 ContentPart::text("kept"),
1531 ];
1532 assert_eq!(
1533 message.to_openai_format(),
1534 json!({"role":"user", "content":"kept"})
1535 );
1536 message.content.remove(1);
1537 assert_eq!(
1538 message.to_openai_format(),
1539 json!({"role":"user", "content":""})
1540 );
1541 let mut assistant = Message::assistant("first");
1542 assistant.content.push(ContentPart::text("second"));
1543 assert_eq!(
1544 assistant.to_openai_format(),
1545 json!({"role":"assistant", "content":"first\nsecond"})
1546 );
1547 }
1548
1549 #[test]
1550 fn message_phase_wire_contract_preserves_optional_source() {
1551 for (phase, wire) in [
1552 (None, None),
1553 (Some(ExecutionPhase::Commentary), Some("commentary")),
1554 (Some(ExecutionPhase::FinalAnswer), Some("final_answer")),
1555 ] {
1556 for source in [
1557 None,
1558 Some(PhaseSource::Provider),
1559 Some(PhaseSource::Derived),
1560 ] {
1561 if phase.is_none() && source.is_some() {
1562 continue;
1563 }
1564 let message = match (phase, source) {
1565 (Some(phase), Some(source)) => {
1566 Message::assistant("answer").with_phase_from(phase, source)
1567 }
1568 (Some(phase), None) => Message::assistant("answer").with_phase(phase),
1569 _ => Message::assistant("answer"),
1570 };
1571 let json = serde_json::to_value(&message).unwrap();
1572 assert_eq!(
1573 json.get("phase"),
1574 wire.map(serde_json::Value::from).as_ref()
1575 );
1576 let source_wire = match source {
1577 Some(PhaseSource::Provider) => Some("provider"),
1578 Some(PhaseSource::Derived) => Some("derived"),
1579 None => None,
1580 };
1581 assert_eq!(
1582 json.get("phase_source"),
1583 source_wire.map(serde_json::Value::from).as_ref()
1584 );
1585 let decoded: Message = serde_json::from_value(json.clone()).unwrap();
1586 assert_eq!(decoded.phase, phase);
1587 assert_eq!(decoded.phase_source, source);
1588 assert_eq!(decoded.text(), Some("answer"));
1589 assert_eq!(serde_json::to_value(decoded).unwrap(), json);
1590 }
1591 }
1592 }
1593
1594 #[test]
1595 fn hints_merge_shallowly_with_message_precedence() {
1596 let session = std::collections::HashMap::from([
1597 ("shared".into(), json!({"old":1})),
1598 ("session_only".into(), json!(42)),
1599 ]);
1600 let message = std::collections::HashMap::from([
1601 ("shared".into(), json!({"new":2})),
1602 ("message_only".into(), json!(null)),
1603 ]);
1604 for (left, right, expected) in [
1605 (None, None, json!({})),
1606 (
1607 Some(&session),
1608 None,
1609 json!({"shared":{"old":1},"session_only":42}),
1610 ),
1611 (
1612 None,
1613 Some(&message),
1614 json!({"shared":{"new":2},"message_only":null}),
1615 ),
1616 (
1617 Some(&session),
1618 Some(&message),
1619 json!({"shared":{"new":2},"session_only":42,"message_only":null}),
1620 ),
1621 ] {
1622 assert_eq!(
1623 serde_json::to_value(Controls::resolve_hints(left, right)).unwrap(),
1624 expected
1625 );
1626 }
1627 }
1628
1629 #[test]
1630 fn controls_wire_contract_preserves_all_overrides_and_legacy_defaults() {
1631 let expected = json!({"model_id":"model_00000000000000000000000000000006", "locale":"uk-UA",
1632 "reasoning":{"effort":"high"}, "speed":"priority", "verbosity":"low", "error_disclosure":"generic",
1633 "hints":{"setup_connection":true,"theme":"dark"}});
1634 let controls = Controls {
1635 model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(6))),
1636 locale: Some("uk-UA".into()),
1637 reasoning: Some(ReasoningConfig {
1638 effort: Some(everruns_provider::model::ReasoningEffort::High),
1639 }),
1640 speed: Some("priority".into()),
1641 verbosity: Some("low".into()),
1642 error_disclosure: Some("generic".into()),
1643 hints: Some(std::collections::HashMap::from([
1644 ("setup_connection".into(), json!(true)),
1645 ("theme".into(), json!("dark")),
1646 ])),
1647 };
1648 assert_eq!(serde_json::to_value(&controls).unwrap(), expected);
1649 assert_eq!(
1650 serde_json::from_value::<Controls>(expected).unwrap(),
1651 controls
1652 );
1653 let legacy: Controls = serde_json::from_value(json!({})).unwrap();
1654 assert_eq!(serde_json::to_value(legacy).unwrap(), json!({}));
1655 }
1656
1657 #[test]
1658 fn tool_result_text_preserves_strings_without_json_escaping() {
1659 let value = serde_json::json!("{\n \"count\": 1\n}");
1660 assert_eq!(
1661 ContentPart::tool_result_text(&value).as_text(),
1662 Some("{\n \"count\": 1\n}")
1663 );
1664 }
1665
1666 #[test]
1667 fn tool_result_text_serializes_structured_values() {
1668 for (value, expected) in [
1669 (json!({"count":1}), "{\"count\":1}"),
1670 (json!([true, 2]), "[true,2]"),
1671 (json!(null), "null"),
1672 ] {
1673 assert_eq!(
1674 ContentPart::tool_result_text(&value).as_text(),
1675 Some(expected)
1676 );
1677 }
1678 }
1679 #[test]
1680 fn file_content_part_serde_roundtrip() {
1681 let part = ContentPart::File(FileContentPart::with_filename(FileId::new(), "report.pdf"));
1682 let v = serde_json::to_value(&part).unwrap();
1683 assert_eq!(v["type"], serde_json::json!("file"));
1684 assert_eq!(v["filename"], serde_json::json!("report.pdf"));
1685 let back: ContentPart = serde_json::from_value(v).unwrap();
1686 assert_eq!(back, part);
1687 assert!(back.is_file());
1688 assert_eq!(back.content_type(), ContentType::File);
1689 assert_eq!(ContentType::File.to_string(), "file");
1690 }
1691}