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")]
591pub enum ContentPart {
592 Text(TextContentPart),
594 Image(ImageContentPart),
596 ImageFile(ImageFileContentPart),
598 File(FileContentPart),
600 ToolCall(ToolCallContentPart),
602 ToolResult(ToolResultContentPart),
604 Reasoning(ReasoningContentPart),
607}
608
609impl ContentPart {
610 pub fn text(text: impl Into<String>) -> Self {
612 ContentPart::Text(TextContentPart::new(text))
613 }
614
615 pub fn tool_result_text(value: &serde_json::Value) -> Self {
618 match value {
619 serde_json::Value::String(text) => Self::text(text.clone()),
620 other => Self::text(other.to_string()),
621 }
622 }
623
624 pub fn image_url(url: impl Into<String>) -> Self {
626 ContentPart::Image(ImageContentPart::from_url(url))
627 }
628
629 pub fn image_file(image_id: ImageId) -> Self {
631 ContentPart::ImageFile(ImageFileContentPart::new(image_id))
632 }
633
634 pub fn file(file_id: FileId) -> Self {
636 ContentPart::File(FileContentPart::new(file_id))
637 }
638
639 pub fn tool_call(
641 id: impl Into<String>,
642 name: impl Into<String>,
643 arguments: serde_json::Value,
644 ) -> Self {
645 ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
646 }
647
648 pub fn tool_result(
650 tool_call_id: impl Into<String>,
651 result: Option<serde_json::Value>,
652 error: Option<String>,
653 ) -> Self {
654 ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
655 }
656
657 pub fn reasoning(part: ReasoningContentPart) -> Self {
659 ContentPart::Reasoning(part)
660 }
661
662 pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
664 match self {
665 ContentPart::Reasoning(r) => Some(r),
666 _ => None,
667 }
668 }
669
670 pub fn is_reasoning(&self) -> bool {
672 matches!(self, ContentPart::Reasoning(_))
673 }
674
675 pub fn as_text(&self) -> Option<&str> {
677 match self {
678 ContentPart::Text(t) => Some(&t.text),
679 _ => None,
680 }
681 }
682
683 pub fn is_image_file(&self) -> bool {
685 matches!(self, ContentPart::ImageFile(_))
686 }
687
688 pub fn is_file(&self) -> bool {
690 matches!(self, ContentPart::File(_))
691 }
692
693 pub fn content_type(&self) -> ContentType {
695 match self {
696 ContentPart::Text(_) => ContentType::Text,
697 ContentPart::Image(_) => ContentType::Image,
698 ContentPart::ImageFile(_) => ContentType::ImageFile,
699 ContentPart::File(_) => ContentType::File,
700 ContentPart::ToolCall(_) => ContentType::ToolCall,
701 ContentPart::ToolResult(_) => ContentType::ToolResult,
702 ContentPart::Reasoning(_) => ContentType::Reasoning,
703 }
704 }
705
706 pub fn to_openai_format(&self) -> Option<serde_json::Value> {
711 match self {
712 ContentPart::Text(t) => Some(serde_json::json!({
713 "type": "text",
714 "text": t.text
715 })),
716 ContentPart::Image(img) => {
717 if let Some(url) = &img.url {
718 Some(serde_json::json!({
719 "type": "image_url",
720 "image_url": { "url": url }
721 }))
722 } else if let Some(b64) = &img.base64 {
723 let media_type = img.media_type.as_deref().unwrap_or("image/png");
724 Some(serde_json::json!({
725 "type": "image_url",
726 "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
727 }))
728 } else {
729 None
730 }
731 }
732 _ => None,
734 }
735 }
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
743#[cfg_attr(feature = "openapi", derive(ToSchema))]
744#[serde(tag = "type", rename_all = "snake_case")]
745pub enum InputContentPart {
746 Text(TextContentPart),
748 Image(ImageContentPart),
750 ImageFile(ImageFileContentPart),
752 File(FileContentPart),
754}
755
756impl From<InputContentPart> for ContentPart {
757 fn from(input: InputContentPart) -> Self {
758 match input {
759 InputContentPart::Text(t) => ContentPart::Text(t),
760 InputContentPart::Image(i) => ContentPart::Image(i),
761 InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
762 InputContentPart::File(f) => ContentPart::File(f),
763 }
764 }
765}
766
767impl InputContentPart {
768 pub fn text(text: impl Into<String>) -> Self {
770 InputContentPart::Text(TextContentPart::new(text))
771 }
772
773 pub fn image_url(url: impl Into<String>) -> Self {
775 InputContentPart::Image(ImageContentPart::from_url(url))
776 }
777
778 pub fn image_file(image_id: ImageId) -> Self {
780 InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
781 }
782
783 pub fn file(file_id: FileId) -> Self {
785 InputContentPart::File(FileContentPart::new(file_id))
786 }
787
788 pub fn as_text(&self) -> Option<&str> {
790 match self {
791 InputContentPart::Text(t) => Some(&t.text),
792 _ => None,
793 }
794 }
795
796 pub fn content_type(&self) -> ContentType {
798 match self {
799 InputContentPart::Text(_) => ContentType::Text,
800 InputContentPart::Image(_) => ContentType::Image,
801 InputContentPart::ImageFile(_) => ContentType::ImageFile,
802 InputContentPart::File(_) => ContentType::File,
803 }
804 }
805}
806
807impl Message {
808 pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
810 self.content.iter().filter_map(ContentPart::as_reasoning)
811 }
812
813 pub fn has_reasoning(&self) -> bool {
815 self.content.iter().any(ContentPart::is_reasoning)
816 }
817
818 pub fn reasoning_display_text(&self) -> Option<String> {
823 let joined = self
824 .reasoning_parts()
825 .filter_map(ReasoningContentPart::display_text)
826 .collect::<Vec<_>>()
827 .join("\n\n");
828 (!joined.is_empty()).then_some(joined)
829 }
830
831 pub fn into_public(mut self) -> Self {
834 for part in &mut self.content {
835 if let ContentPart::Reasoning(r) = part {
836 *r = r.to_public();
837 }
838 }
839 self
840 }
841
842 pub fn with_id(mut self, id: MessageId) -> Self {
847 self.id = id;
848 self
849 }
850
851 pub fn user(content: impl Into<String>) -> Self {
853 Self {
854 id: MessageId::new(),
855 role: MessageRole::User,
856 content: vec![ContentPart::text(content)],
857 phase: None,
858 phase_source: None,
859 controls: None,
860 metadata: None,
861 external_actor: None,
862 created_at: Utc::now(),
863 }
864 }
865
866 pub fn assistant(content: impl Into<String>) -> Self {
868 Self {
869 id: MessageId::new(),
870 role: MessageRole::Agent,
871 content: vec![ContentPart::text(content)],
872 phase: None,
873 phase_source: None,
874 controls: None,
875 metadata: None,
876 external_actor: None,
877 created_at: Utc::now(),
878 }
879 }
880
881 pub fn assistant_with_tools(
887 content: impl Into<String>,
888 tool_calls: Vec<crate::tool_types::ToolCall>,
889 ) -> Self {
890 let text_content = content.into();
891 let mut parts = Vec::new();
892 if !text_content.is_empty() {
894 parts.push(ContentPart::text(text_content));
895 }
896 for tc in tool_calls {
897 parts.push(ContentPart::ToolCall(ToolCallContentPart {
898 native: None,
899 id: tc.id,
900 name: tc.name,
901 arguments: tc.arguments,
902 }));
903 }
904 Self {
905 id: MessageId::new(),
906 role: MessageRole::Agent,
907 content: parts,
908 phase: None,
909 phase_source: None,
910 controls: None,
911 metadata: None,
912 external_actor: None,
913 created_at: Utc::now(),
914 }
915 }
916
917 pub fn system(content: impl Into<String>) -> Self {
919 Self {
920 id: MessageId::new(),
921 role: MessageRole::System,
922 content: vec![ContentPart::text(content)],
923 phase: None,
924 phase_source: None,
925 controls: None,
926 metadata: None,
927 external_actor: None,
928 created_at: Utc::now(),
929 }
930 }
931
932 pub fn tool_result(
934 tool_call_id: impl Into<String>,
935 result: Option<serde_json::Value>,
936 error: Option<String>,
937 ) -> Self {
938 let tool_call_id = tool_call_id.into();
939 Self {
940 id: MessageId::new(),
941 role: MessageRole::ToolResult,
942 content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
943 tool_call_id,
944 result,
945 error,
946 ))],
947 phase: None,
948 phase_source: None,
949 controls: None,
950 metadata: None,
951 external_actor: None,
952 created_at: Utc::now(),
953 }
954 }
955
956 pub fn tool_result_with_images(
962 tool_call_id: impl Into<String>,
963 result: Option<serde_json::Value>,
964 images: Vec<everruns_provider::tool_types::ToolResultImage>,
965 ) -> Self {
966 let tool_call_id = tool_call_id.into();
967 let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
968 tool_call_id,
969 result,
970 None,
971 ))];
972 for img in images {
973 content.push(ContentPart::Image(ImageContentPart::from_base64(
974 img.base64,
975 img.media_type,
976 )));
977 }
978 Self {
979 id: MessageId::new(),
980 role: MessageRole::ToolResult,
981 content,
982 phase: None,
983 phase_source: None,
984 controls: None,
985 metadata: None,
986 external_actor: None,
987 created_at: Utc::now(),
988 }
989 }
990
991 pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
993 self.phase = Some(phase);
994 self
995 }
996
997 pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
999 self.phase = Some(phase);
1000 self.phase_source = Some(source);
1001 self
1002 }
1003
1004 pub fn tool_call_id(&self) -> Option<&str> {
1008 self.content.iter().find_map(|p| match p {
1009 ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
1010 _ => None,
1011 })
1012 }
1013
1014 pub fn text(&self) -> Option<&str> {
1016 self.content.iter().find_map(|p| p.as_text())
1017 }
1018
1019 pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
1021 self.content
1022 .iter()
1023 .filter_map(|p| match p {
1024 ContentPart::ToolCall(tc) => Some(tc),
1025 _ => None,
1026 })
1027 .collect()
1028 }
1029
1030 pub fn has_tool_calls(&self) -> bool {
1032 self.content
1033 .iter()
1034 .any(|p| matches!(p, ContentPart::ToolCall(_)))
1035 }
1036
1037 pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
1039 self.content.iter().find_map(|p| match p {
1040 ContentPart::ToolResult(tr) => Some(tr),
1041 _ => None,
1042 })
1043 }
1044
1045 pub fn content_to_llm_string(&self) -> String {
1047 self.content
1048 .iter()
1049 .map(|part| match part {
1050 ContentPart::Text(t) => t.text.clone(),
1051 ContentPart::Reasoning(_) => String::new(),
1055 ContentPart::Image(_) => "[Image]".to_string(),
1056 ContentPart::ImageFile(_) => "[Image File]".to_string(),
1057 ContentPart::File(part) => part
1058 .filename
1059 .clone()
1060 .map(|n| format!("[PDF File: {}]", n))
1061 .unwrap_or_else(|| "[PDF File]".to_string()),
1062 ContentPart::ToolCall(tc) => {
1063 format!(
1064 "Tool call: {} with arguments: {}",
1065 tc.name,
1066 serde_json::to_string(&tc.arguments).unwrap_or_default()
1067 )
1068 }
1069 ContentPart::ToolResult(tr) => {
1070 if let Some(err) = &tr.error {
1071 format!("Tool error: {}", err)
1072 } else if let Some(res) = &tr.result {
1073 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1074 } else {
1075 "{}".to_string()
1076 }
1077 }
1078 })
1079 .filter(|rendered| !rendered.is_empty())
1080 .collect::<Vec<_>>()
1081 .join("\n")
1082 }
1083
1084 pub fn to_openai_format(&self) -> serde_json::Value {
1093 let role = match self.role {
1094 MessageRole::System => "system",
1095 MessageRole::User => "user",
1096 MessageRole::Agent => "assistant",
1097 MessageRole::ToolResult => "tool",
1098 };
1099
1100 if self.role == MessageRole::ToolResult {
1102 let tool_call_id = self.tool_call_id().unwrap_or("");
1103 let content = self
1104 .content
1105 .iter()
1106 .find_map(|p| match p {
1107 ContentPart::ToolResult(tr) => {
1108 if let Some(error) = &tr.error {
1109 Some(format!("Error: {}", error))
1110 } else if let Some(result) = &tr.result {
1111 Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
1112 } else {
1113 Some("{}".to_string())
1114 }
1115 }
1116 _ => None,
1117 })
1118 .unwrap_or_else(|| "{}".to_string());
1119
1120 return serde_json::json!({
1121 "role": role,
1122 "content": content,
1123 "tool_call_id": tool_call_id
1124 });
1125 }
1126
1127 if self.role == MessageRole::Agent {
1129 let tool_calls: Vec<serde_json::Value> = self
1130 .content
1131 .iter()
1132 .filter_map(|p| match p {
1133 ContentPart::ToolCall(tc) => Some(serde_json::json!({
1134 "id": tc.id,
1135 "type": "function",
1136 "function": {
1137 "name": tc.name,
1138 "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
1139 }
1140 })),
1141 _ => None,
1142 })
1143 .collect();
1144
1145 let text_content: String = self
1146 .content
1147 .iter()
1148 .filter_map(|p| match p {
1149 ContentPart::Text(t) => Some(t.text.clone()),
1150 _ => None,
1151 })
1152 .collect::<Vec<_>>()
1153 .join("\n");
1154
1155 if tool_calls.is_empty() {
1156 return serde_json::json!({
1157 "role": role,
1158 "content": text_content
1159 });
1160 } else {
1161 let mut result = serde_json::json!({
1162 "role": role,
1163 "tool_calls": tool_calls
1164 });
1165 if !text_content.is_empty() {
1166 result["content"] = serde_json::json!(text_content);
1167 }
1168 return result;
1169 }
1170 }
1171
1172 let content = self.content_to_openai_format();
1174 serde_json::json!({
1175 "role": role,
1176 "content": content
1177 })
1178 }
1179
1180 fn content_to_openai_format(&self) -> serde_json::Value {
1182 if self.content.len() == 1
1184 && let ContentPart::Text(t) = &self.content[0]
1185 {
1186 return serde_json::json!(t.text);
1187 }
1188
1189 let parts: Vec<serde_json::Value> = self
1191 .content
1192 .iter()
1193 .filter_map(|part| part.to_openai_format())
1194 .collect();
1195
1196 if parts.is_empty() {
1197 return serde_json::json!("");
1198 }
1199
1200 if parts.len() == 1
1202 && let Some(text) = parts[0].get("text")
1203 {
1204 return text.clone();
1205 }
1206
1207 serde_json::json!(parts)
1208 }
1209}
1210
1211pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
1221 let mut result = Vec::new();
1222
1223 for (i, msg) in messages.iter().enumerate() {
1224 result.push(msg.clone());
1225
1226 if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1228 for tc in msg.tool_calls() {
1229 let has_result = messages[(i + 1)..]
1231 .iter()
1232 .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
1233
1234 if !has_result {
1235 result.push(Message::tool_result(
1236 &tc.id,
1237 None,
1238 Some(
1239 "cancelled - another message came in before it could be completed"
1240 .to_string(),
1241 ),
1242 ));
1243 }
1244 }
1245 }
1246 }
1247
1248 result
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::*;
1254 use crate::tool_types::ToolCall;
1255 use serde_json::json;
1256
1257 fn calls() -> Vec<ToolCall> {
1258 vec![
1259 ToolCall {
1260 id: "call_search".into(),
1261 name: "search".into(),
1262 arguments: json!({"q": "rust"}),
1263 },
1264 ToolCall {
1265 id: "call_fetch".into(),
1266 name: "fetch".into(),
1267 arguments: json!({"url": "https://example.com"}),
1268 },
1269 ]
1270 }
1271
1272 fn assert_messages(actual: &[Message], expected: &[Message]) {
1273 assert_eq!(
1274 serde_json::to_value(actual).unwrap(),
1275 serde_json::to_value(expected).unwrap()
1276 );
1277 }
1278
1279 #[test]
1280 fn native_custom_call_survives_transcript_serialization_and_conversion() {
1281 let native = everruns_provider::native_async::NativeToolCall::Custom {
1282 call_id: "original-call".into(),
1283 name: "lookup".into(),
1284 input: "raw\nquery: \"value\"".into(),
1285 asynchronous: true,
1286 };
1287 let mut message = Message::assistant("");
1288 message.content.push(ContentPart::ToolCall(
1289 ToolCallContentPart::from_native(native.clone()).unwrap(),
1290 ));
1291 let restored: Message =
1292 serde_json::from_slice(&serde_json::to_vec(&message).unwrap()).unwrap();
1293 assert_eq!(restored.tool_calls()[0].native.as_ref(), Some(&native));
1294 let llm = crate::llm_conversions::llm_message_from_message(&restored);
1295 assert_eq!(llm.native_tool_calls, vec![native]);
1296 assert_eq!(llm.tool_calls.unwrap()[0].id, "original-call");
1297 }
1298
1299 #[test]
1300 fn settled_transcripts_are_preserved_without_synthetic_results() {
1301 for messages in [
1302 vec![],
1303 vec![Message::user("Hello"), Message::assistant("Hi")],
1304 vec![
1305 Message::assistant_with_tools("Searching", vec![calls()[0].clone()]),
1306 Message::tool_result("call_search", Some(json!({"found": 2})), None),
1307 ],
1308 ] {
1309 assert_messages(&patch_dangling_tool_calls(&messages), &messages);
1310 }
1311 }
1312
1313 #[test]
1314 fn dangling_calls_get_only_missing_cancellations_and_patching_is_idempotent() {
1315 let messages = vec![
1316 Message::user("Search then fetch"),
1317 Message::assistant_with_tools("Working", calls()),
1318 Message::user("Never mind"),
1319 Message::tool_result("call_search", Some(json!({"found": 2})), None),
1320 ];
1321 let patched = patch_dangling_tool_calls(&messages);
1322 assert_eq!(patched.len(), 5);
1323 assert_messages(&patched[..2], &messages[..2]);
1324 assert_messages(&patched[3..], &messages[2..]);
1325 assert_eq!(patched[2].role, MessageRole::ToolResult);
1326 assert_eq!(
1327 serde_json::to_value(&patched[2].content).unwrap(),
1328 json!([{
1329 "type": "tool_result", "tool_call_id": "call_fetch",
1330 "error": "cancelled - another message came in before it could be completed"
1331 }])
1332 );
1333 assert_messages(&patch_dangling_tool_calls(&patched), &patched);
1334 }
1335
1336 #[test]
1337 fn plain_message_constructors_preserve_role_and_text() {
1338 for (message, role, text) in [
1339 (Message::user("question"), MessageRole::User, "question"),
1340 (Message::assistant("answer"), MessageRole::Agent, "answer"),
1341 (
1342 Message::system("instruction"),
1343 MessageRole::System,
1344 "instruction",
1345 ),
1346 ] {
1347 assert_eq!(message.role, role);
1348 assert_eq!(message.text(), Some(text));
1349 assert_eq!(message.content, vec![ContentPart::text(text)]);
1350 assert!(!message.has_tool_calls());
1351 }
1352 }
1353
1354 #[test]
1355 fn tool_result_constructor_preserves_result_and_error_fields() {
1356 for (result, error) in [
1357 (Some(json!({"count": 2})), None),
1358 (None, Some("timeout".to_owned())),
1359 (Some(json!(false)), Some("partial".to_owned())),
1360 ] {
1361 let message = Message::tool_result("call_result", result.clone(), error.clone());
1362 assert_eq!(message.role, MessageRole::ToolResult);
1363 assert_eq!(message.tool_call_id(), Some("call_result"));
1364 assert_eq!(
1365 message.content,
1366 vec![ContentPart::tool_result("call_result", result, error)]
1367 );
1368 }
1369 }
1370
1371 #[test]
1372 fn assistant_tool_messages_preserve_calls_and_distinguish_empty_from_whitespace_text() {
1373 for text in ["", " ", "Working"] {
1374 let message = Message::assistant_with_tools(text, calls());
1375 let tool_parts: Vec<_> = calls()
1376 .into_iter()
1377 .map(|c| ContentPart::tool_call(c.id, c.name, c.arguments))
1378 .collect();
1379 let mut expected = vec![];
1380 if !text.is_empty() {
1381 expected.push(ContentPart::text(text));
1382 }
1383 expected.extend(tool_parts);
1384 assert_eq!(message.role, MessageRole::Agent);
1385 assert_eq!(message.text(), (!text.is_empty()).then_some(text));
1386 assert_eq!(message.content, expected);
1387 assert!(message.has_tool_calls());
1388 assert_eq!(
1389 serde_json::to_value(message.tool_calls()).unwrap(),
1390 serde_json::to_value(calls()).unwrap()
1391 );
1392 }
1393 }
1394
1395 #[test]
1396 fn openai_plain_messages_map_internal_roles_and_preserve_text() {
1397 for (message, expected) in [
1398 (
1399 Message::user("question"),
1400 json!({"role": "user", "content": "question"}),
1401 ),
1402 (
1403 Message::system("instruction"),
1404 json!({"role": "system", "content": "instruction"}),
1405 ),
1406 (
1407 Message::assistant("answer"),
1408 json!({"role": "assistant", "content": "answer"}),
1409 ),
1410 ] {
1411 assert_eq!(message.to_openai_format(), expected);
1412 }
1413 }
1414
1415 #[test]
1416 fn openai_tool_calls_preserve_ids_arguments_and_optional_text() {
1417 for text in ["", "Working"] {
1418 let message = Message::assistant_with_tools(text, calls());
1419 let mut expected = json!({"role": "assistant", "tool_calls": [
1420 {"id": "call_search", "type": "function", "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}},
1421 {"id": "call_fetch", "type": "function", "function": {"name": "fetch", "arguments": "{\"url\":\"https://example.com\"}"}}
1422 ]});
1423 if !text.is_empty() {
1424 expected["content"] = text.into();
1425 }
1426 assert_eq!(message.to_openai_format(), expected);
1427 }
1428 }
1429
1430 #[test]
1431 fn openai_tool_results_prefer_errors_and_preserve_call_identity() {
1432 for (result, error, content) in [
1433 (
1434 Some(json!({"temperature":72})),
1435 None,
1436 "{\"temperature\":72}",
1437 ),
1438 (None, Some("timeout"), "Error: timeout"),
1439 (
1440 Some(json!({"partial":true})),
1441 Some("partial failure"),
1442 "Error: partial failure",
1443 ),
1444 (None, None, "{}"),
1445 ] {
1446 let message = Message::tool_result("call_result", result, error.map(str::to_owned));
1447 assert_eq!(
1448 message.to_openai_format(),
1449 json!({"role":"tool", "tool_call_id":"call_result", "content":content})
1450 );
1451 }
1452 }
1453
1454 #[test]
1455 fn openai_content_parts_preserve_text_and_image_sources() {
1456 for (part, expected) in [
1457 (
1458 ContentPart::text("Hello"),
1459 json!({"type":"text", "text":"Hello"}),
1460 ),
1461 (
1462 ContentPart::image_url("https://example.com/img.png"),
1463 json!({"type":"image_url", "image_url":{"url":"https://example.com/img.png"}}),
1464 ),
1465 (
1466 ContentPart::Image(ImageContentPart::from_base64("YWJj", "image/jpeg")),
1467 json!({"type":"image_url", "image_url":{"url":"data:image/jpeg;base64,YWJj"}}),
1468 ),
1469 (
1470 ContentPart::Image(ImageContentPart {
1471 url: None,
1472 base64: Some("YWJj".into()),
1473 media_type: None,
1474 }),
1475 json!({"type":"image_url", "image_url":{"url":"data:image/png;base64,YWJj"}}),
1476 ),
1477 (
1478 ContentPart::Image(ImageContentPart {
1479 url: Some("https://example.com/preferred".into()),
1480 base64: Some("YWJj".into()),
1481 media_type: Some("image/jpeg".into()),
1482 }),
1483 json!({"type":"image_url", "image_url":{"url":"https://example.com/preferred"}}),
1484 ),
1485 ] {
1486 assert_eq!(part.to_openai_format(), Some(expected));
1487 }
1488 assert!(
1489 ContentPart::Image(ImageContentPart {
1490 url: None,
1491 base64: None,
1492 media_type: None
1493 })
1494 .to_openai_format()
1495 .is_none()
1496 );
1497 }
1498
1499 #[test]
1500 fn openai_content_parts_exclude_tool_file_and_reasoning_artifacts() {
1501 for part in [
1502 ContentPart::tool_call("call_1", "lookup", json!({})),
1503 ContentPart::tool_result("call_1", Some(json!(42)), None),
1504 ContentPart::image_file(ImageId::new()),
1505 ContentPart::reasoning(
1506 ReasoningContentPart::opaque("test").with_signature("private-signature"),
1507 ),
1508 ] {
1509 assert!(part.to_openai_format().is_none());
1510 }
1511 }
1512
1513 #[test]
1514 fn openai_message_content_preserves_multimodal_order_and_filters_unsupported_parts() {
1515 let mut message = Message::user("before");
1516 message
1517 .content
1518 .push(ContentPart::image_url("https://example.com/image"));
1519 message.content.push(ContentPart::text("after"));
1520 assert_eq!(
1521 message.to_openai_format(),
1522 json!({"role":"user", "content":[
1523 {"type":"text", "text":"before"}, {"type":"image_url", "image_url":{"url":"https://example.com/image"}},
1524 {"type":"text", "text":"after"}
1525 ]})
1526 );
1527 message.content = vec![
1528 ContentPart::tool_call("ignored", "tool", json!({})),
1529 ContentPart::text("kept"),
1530 ];
1531 assert_eq!(
1532 message.to_openai_format(),
1533 json!({"role":"user", "content":"kept"})
1534 );
1535 message.content.remove(1);
1536 assert_eq!(
1537 message.to_openai_format(),
1538 json!({"role":"user", "content":""})
1539 );
1540 let mut assistant = Message::assistant("first");
1541 assistant.content.push(ContentPart::text("second"));
1542 assert_eq!(
1543 assistant.to_openai_format(),
1544 json!({"role":"assistant", "content":"first\nsecond"})
1545 );
1546 }
1547
1548 #[test]
1549 fn message_phase_wire_contract_preserves_optional_source() {
1550 for (phase, wire) in [
1551 (None, None),
1552 (Some(ExecutionPhase::Commentary), Some("commentary")),
1553 (Some(ExecutionPhase::FinalAnswer), Some("final_answer")),
1554 ] {
1555 for source in [
1556 None,
1557 Some(PhaseSource::Provider),
1558 Some(PhaseSource::Derived),
1559 ] {
1560 if phase.is_none() && source.is_some() {
1561 continue;
1562 }
1563 let message = match (phase, source) {
1564 (Some(phase), Some(source)) => {
1565 Message::assistant("answer").with_phase_from(phase, source)
1566 }
1567 (Some(phase), None) => Message::assistant("answer").with_phase(phase),
1568 _ => Message::assistant("answer"),
1569 };
1570 let json = serde_json::to_value(&message).unwrap();
1571 assert_eq!(
1572 json.get("phase"),
1573 wire.map(serde_json::Value::from).as_ref()
1574 );
1575 let source_wire = match source {
1576 Some(PhaseSource::Provider) => Some("provider"),
1577 Some(PhaseSource::Derived) => Some("derived"),
1578 None => None,
1579 };
1580 assert_eq!(
1581 json.get("phase_source"),
1582 source_wire.map(serde_json::Value::from).as_ref()
1583 );
1584 let decoded: Message = serde_json::from_value(json.clone()).unwrap();
1585 assert_eq!(decoded.phase, phase);
1586 assert_eq!(decoded.phase_source, source);
1587 assert_eq!(decoded.text(), Some("answer"));
1588 assert_eq!(serde_json::to_value(decoded).unwrap(), json);
1589 }
1590 }
1591 }
1592
1593 #[test]
1594 fn hints_merge_shallowly_with_message_precedence() {
1595 let session = std::collections::HashMap::from([
1596 ("shared".into(), json!({"old":1})),
1597 ("session_only".into(), json!(42)),
1598 ]);
1599 let message = std::collections::HashMap::from([
1600 ("shared".into(), json!({"new":2})),
1601 ("message_only".into(), json!(null)),
1602 ]);
1603 for (left, right, expected) in [
1604 (None, None, json!({})),
1605 (
1606 Some(&session),
1607 None,
1608 json!({"shared":{"old":1},"session_only":42}),
1609 ),
1610 (
1611 None,
1612 Some(&message),
1613 json!({"shared":{"new":2},"message_only":null}),
1614 ),
1615 (
1616 Some(&session),
1617 Some(&message),
1618 json!({"shared":{"new":2},"session_only":42,"message_only":null}),
1619 ),
1620 ] {
1621 assert_eq!(
1622 serde_json::to_value(Controls::resolve_hints(left, right)).unwrap(),
1623 expected
1624 );
1625 }
1626 }
1627
1628 #[test]
1629 fn controls_wire_contract_preserves_all_overrides_and_legacy_defaults() {
1630 let expected = json!({"model_id":"model_00000000000000000000000000000006", "locale":"uk-UA",
1631 "reasoning":{"effort":"high"}, "speed":"priority", "verbosity":"low", "error_disclosure":"generic",
1632 "hints":{"setup_connection":true,"theme":"dark"}});
1633 let controls = Controls {
1634 model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(6))),
1635 locale: Some("uk-UA".into()),
1636 reasoning: Some(ReasoningConfig {
1637 effort: Some(everruns_provider::model::ReasoningEffort::High),
1638 }),
1639 speed: Some("priority".into()),
1640 verbosity: Some("low".into()),
1641 error_disclosure: Some("generic".into()),
1642 hints: Some(std::collections::HashMap::from([
1643 ("setup_connection".into(), json!(true)),
1644 ("theme".into(), json!("dark")),
1645 ])),
1646 };
1647 assert_eq!(serde_json::to_value(&controls).unwrap(), expected);
1648 assert_eq!(
1649 serde_json::from_value::<Controls>(expected).unwrap(),
1650 controls
1651 );
1652 let legacy: Controls = serde_json::from_value(json!({})).unwrap();
1653 assert_eq!(serde_json::to_value(legacy).unwrap(), json!({}));
1654 }
1655
1656 #[test]
1657 fn tool_result_text_preserves_strings_without_json_escaping() {
1658 let value = serde_json::json!("{\n \"count\": 1\n}");
1659 assert_eq!(
1660 ContentPart::tool_result_text(&value).as_text(),
1661 Some("{\n \"count\": 1\n}")
1662 );
1663 }
1664
1665 #[test]
1666 fn tool_result_text_serializes_structured_values() {
1667 for (value, expected) in [
1668 (json!({"count":1}), "{\"count\":1}"),
1669 (json!([true, 2]), "[true,2]"),
1670 (json!(null), "null"),
1671 ] {
1672 assert_eq!(
1673 ContentPart::tool_result_text(&value).as_text(),
1674 Some(expected)
1675 );
1676 }
1677 }
1678 #[test]
1679 fn file_content_part_serde_roundtrip() {
1680 let part = ContentPart::File(FileContentPart::with_filename(FileId::new(), "report.pdf"));
1681 let v = serde_json::to_value(&part).unwrap();
1682 assert_eq!(v["type"], serde_json::json!("file"));
1683 assert_eq!(v["filename"], serde_json::json!("report.pdf"));
1684 let back: ContentPart = serde_json::from_value(v).unwrap();
1685 assert_eq!(back, part);
1686 assert!(back.is_file());
1687 assert_eq!(back.content_type(), ContentType::File);
1688 assert_eq!(ContentType::File.to_string(), "file");
1689 }
1690}