1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::typed_id::{FileId, ImageId, MessageId, ModelId};
11
12#[cfg(feature = "openapi")]
13use utoipa::ToSchema;
14
15use everruns_contracts::execution_phase::{ExecutionPhase, PhaseSource};
16use everruns_contracts::message::ProviderOpaqueContent;
17use everruns_contracts::reasoning::ReasoningContentPart;
18mod turn_scope;
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(ToSchema))]
22#[serde(rename_all = "snake_case")]
27pub enum RuntimeMessageRole {
28 System,
30 User,
32 Agent,
34 ToolResult,
36}
37
38impl std::fmt::Display for RuntimeMessageRole {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 RuntimeMessageRole::System => write!(f, "system"),
42 RuntimeMessageRole::User => write!(f, "user"),
43 RuntimeMessageRole::Agent => write!(f, "agent"),
44 RuntimeMessageRole::ToolResult => write!(f, "tool_result"),
45 }
46 }
47}
48
49impl From<&str> for RuntimeMessageRole {
50 fn from(s: &str) -> Self {
51 match s.to_lowercase().as_str() {
52 "system" => RuntimeMessageRole::System,
53 "user" => RuntimeMessageRole::User,
54 "agent" | "assistant" => RuntimeMessageRole::Agent,
56 "tool_result" => RuntimeMessageRole::ToolResult,
57 _ => RuntimeMessageRole::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_contracts::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")]
135 pub speed: Option<String>,
136
137 #[serde(skip_serializing_if = "Option::is_none")]
141 pub verbosity: Option<String>,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
148 pub error_disclosure: Option<String>,
149
150 #[serde(default, skip_serializing_if = "Option::is_none")]
156 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
157 pub hints: Option<std::collections::HashMap<String, serde_json::Value>>,
158}
159
160impl Controls {
161 pub fn resolve_hints(
164 session_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
165 message_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
166 ) -> std::collections::HashMap<String, serde_json::Value> {
167 match (session_hints, message_hints) {
168 (None, None) => std::collections::HashMap::new(),
169 (Some(s), None) => s.clone(),
170 (None, Some(m)) => m.clone(),
171 (Some(s), Some(m)) => {
172 let mut merged = s.clone();
173 merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
174 merged
175 }
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(ToSchema))]
183pub struct RuntimeMessage {
190 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
192 pub id: MessageId,
193
194 pub role: RuntimeMessageRole,
196
197 pub content: Vec<ContentPart>,
199
200 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub phase: Option<ExecutionPhase>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub phase_source: Option<PhaseSource>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub controls: Option<Controls>,
219
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
223 pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub external_actor: Option<ExternalActor>,
228
229 pub created_at: DateTime<Utc>,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
239#[cfg_attr(feature = "openapi", derive(ToSchema))]
240#[serde(rename_all = "snake_case")]
241pub enum ContentType {
242 Text,
243 Image,
244 ImageFile,
245 File,
247 ToolCall,
248 ToolResult,
249 Reasoning,
250 ProviderOpaque,
252}
253
254impl std::fmt::Display for ContentType {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 ContentType::Text => write!(f, "text"),
258 ContentType::Image => write!(f, "image"),
259 ContentType::ImageFile => write!(f, "image_file"),
260 ContentType::File => write!(f, "file"),
261 ContentType::ToolCall => write!(f, "tool_call"),
262 ContentType::ToolResult => write!(f, "tool_result"),
263 ContentType::Reasoning => write!(f, "reasoning"),
264 ContentType::ProviderOpaque => write!(f, "provider_opaque"),
265 }
266 }
267}
268
269impl From<&str> for ContentType {
270 fn from(s: &str) -> Self {
271 match s {
272 "image" => ContentType::Image,
273 "image_file" => ContentType::ImageFile,
274 "tool_call" => ContentType::ToolCall,
275 "tool_result" => ContentType::ToolResult,
276 "reasoning" => ContentType::Reasoning,
277 "provider_opaque" => ContentType::ProviderOpaque,
278 _ => ContentType::Text,
279 }
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
289#[cfg_attr(feature = "openapi", derive(ToSchema))]
290pub struct TextContentPart {
291 pub text: String,
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
298 pub annotations: Vec<TextAnnotation>,
299}
300
301impl TextContentPart {
302 pub fn new(text: impl Into<String>) -> Self {
303 Self {
304 text: text.into(),
305 annotations: Vec::new(),
306 }
307 }
308
309 pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
311 self.annotations = annotations;
312 self
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
323#[cfg_attr(feature = "openapi", derive(ToSchema))]
324pub struct TextAnnotation {
325 #[cfg_attr(feature = "openapi", schema(example = 0))]
327 pub start: usize,
328 #[cfg_attr(feature = "openapi", schema(example = 19))]
330 pub end: usize,
331 #[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
334 pub origin: String,
335 pub source: AnnotationSource,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 #[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
341 pub external_id: Option<String>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub verified: Option<VerificationVerdict>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
350#[cfg_attr(feature = "openapi", derive(ToSchema))]
351pub struct AnnotationSource {
352 #[cfg_attr(
355 feature = "openapi",
356 schema(example = "github://owner/repo@main/docs/x.md")
357 )]
358 pub uri: String,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 #[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
362 pub title: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
366 #[cfg_attr(
367 feature = "openapi",
368 schema(example = "The control plane owns durable state.")
369 )]
370 pub snippet: Option<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub location: Option<serde_json::Value>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379#[cfg_attr(feature = "openapi", derive(ToSchema))]
380pub struct VerificationVerdict {
381 pub status: VerificationStatus,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 #[cfg_attr(feature = "openapi", schema(example = 0.92))]
386 pub score: Option<f32>,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "openapi", derive(ToSchema))]
392#[cfg_attr(feature = "openapi", schema(example = "entailed"))]
393#[serde(rename_all = "snake_case")]
394pub enum VerificationStatus {
395 Entailed,
397 Unsupported,
399 Uncertain,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
405#[cfg_attr(feature = "openapi", derive(ToSchema))]
406pub struct ImageContentPart {
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub url: Option<String>,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub base64: Option<String>,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub media_type: Option<String>,
413}
414
415impl ImageContentPart {
416 pub fn from_url(url: impl Into<String>) -> Self {
417 Self {
418 url: Some(url.into()),
419 base64: None,
420 media_type: None,
421 }
422 }
423
424 pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
425 Self {
426 url: None,
427 base64: Some(base64.into()),
428 media_type: Some(media_type.into()),
429 }
430 }
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
439#[cfg_attr(feature = "openapi", derive(ToSchema))]
440pub struct ImageFileContentPart {
441 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
443 pub image_id: ImageId,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 pub filename: Option<String>,
447}
448
449impl ImageFileContentPart {
450 pub fn new(image_id: ImageId) -> Self {
451 Self {
452 image_id,
453 filename: None,
454 }
455 }
456
457 pub fn with_filename(image_id: ImageId, filename: impl Into<String>) -> Self {
458 Self {
459 image_id,
460 filename: Some(filename.into()),
461 }
462 }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
470#[cfg_attr(feature = "openapi", derive(ToSchema))]
471pub struct FileContentPart {
472 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "file_01933b5a00007000800000000000001"))]
474 pub file_id: FileId,
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub filename: Option<String>,
478}
479
480impl FileContentPart {
481 pub fn new(file_id: FileId) -> Self {
483 Self {
484 file_id,
485 filename: None,
486 }
487 }
488
489 pub fn with_filename(file_id: FileId, filename: impl Into<String>) -> Self {
491 Self {
492 file_id,
493 filename: Some(filename.into()),
494 }
495 }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
500#[cfg_attr(feature = "openapi", derive(ToSchema))]
501pub struct ToolCallContentPart {
502 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub native: Option<everruns_contracts::native_async::NativeToolCall>,
505 pub id: String,
506 pub name: String,
507 pub arguments: serde_json::Value,
508}
509
510impl ToolCallContentPart {
511 pub fn from_native(
513 call: everruns_contracts::native_async::NativeToolCall,
514 ) -> crate::error::Result<Self> {
515 use everruns_contracts::native_async::NativeToolCall;
516 call.validate()?;
517 let arguments = match &call {
518 NativeToolCall::Function { arguments, .. } => serde_json::from_str(arguments)
519 .map_err(|error| crate::error::AgentLoopError::llm(error.to_string()))?,
520 NativeToolCall::Custom { input, .. } => serde_json::Value::String(input.clone()),
521 };
522 Ok(Self {
523 id: call.id().into(),
524 name: call.name().into(),
525 arguments,
526 native: Some(call),
527 })
528 }
529
530 pub fn new(
531 id: impl Into<String>,
532 name: impl Into<String>,
533 arguments: serde_json::Value,
534 ) -> Self {
535 Self {
536 native: None,
537 id: id.into(),
538 name: name.into(),
539 arguments,
540 }
541 }
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
546#[cfg_attr(feature = "openapi", derive(ToSchema))]
547pub struct ToolResultContentPart {
548 pub tool_call_id: String,
550 #[serde(skip_serializing_if = "Option::is_none")]
551 pub result: Option<serde_json::Value>,
552 #[serde(skip_serializing_if = "Option::is_none")]
553 pub error: Option<String>,
554}
555
556impl ToolResultContentPart {
557 pub fn new(
558 tool_call_id: impl Into<String>,
559 result: Option<serde_json::Value>,
560 error: Option<String>,
561 ) -> Self {
562 Self {
563 tool_call_id: tool_call_id.into(),
564 result,
565 error,
566 }
567 }
568
569 pub fn success(tool_call_id: impl Into<String>, result: serde_json::Value) -> Self {
570 Self {
571 tool_call_id: tool_call_id.into(),
572 result: Some(result),
573 error: None,
574 }
575 }
576
577 pub fn error(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
578 Self {
579 tool_call_id: tool_call_id.into(),
580 result: None,
581 error: Some(error.into()),
582 }
583 }
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
595#[cfg_attr(feature = "openapi", derive(ToSchema))]
596#[serde(tag = "type", rename_all = "snake_case")]
597#[non_exhaustive]
598pub enum ContentPart {
599 Text(TextContentPart),
601 Image(ImageContentPart),
603 ImageFile(ImageFileContentPart),
605 File(FileContentPart),
607 ToolCall(ToolCallContentPart),
609 ToolResult(ToolResultContentPart),
611 Reasoning(ReasoningContentPart),
614 ProviderOpaque(ProviderOpaqueContent),
616}
617
618impl ContentPart {
619 pub fn text(text: impl Into<String>) -> Self {
621 ContentPart::Text(TextContentPart::new(text))
622 }
623
624 pub fn tool_result_text(value: &serde_json::Value) -> Self {
627 match value {
628 serde_json::Value::String(text) => Self::text(text.clone()),
629 other => Self::text(other.to_string()),
630 }
631 }
632
633 pub fn image_url(url: impl Into<String>) -> Self {
635 ContentPart::Image(ImageContentPart::from_url(url))
636 }
637
638 pub fn image_file(image_id: ImageId) -> Self {
640 ContentPart::ImageFile(ImageFileContentPart::new(image_id))
641 }
642
643 pub fn file(file_id: FileId) -> Self {
645 ContentPart::File(FileContentPart::new(file_id))
646 }
647
648 pub fn tool_call(
650 id: impl Into<String>,
651 name: impl Into<String>,
652 arguments: serde_json::Value,
653 ) -> Self {
654 ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
655 }
656
657 pub fn tool_result(
659 tool_call_id: impl Into<String>,
660 result: Option<serde_json::Value>,
661 error: Option<String>,
662 ) -> Self {
663 ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
664 }
665
666 pub fn reasoning(part: ReasoningContentPart) -> Self {
668 ContentPart::Reasoning(part)
669 }
670
671 pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
673 match self {
674 ContentPart::Reasoning(r) => Some(r),
675 _ => None,
676 }
677 }
678
679 pub fn is_reasoning(&self) -> bool {
681 matches!(self, ContentPart::Reasoning(_))
682 }
683
684 pub fn as_text(&self) -> Option<&str> {
686 match self {
687 ContentPart::Text(t) => Some(&t.text),
688 _ => None,
689 }
690 }
691
692 pub fn is_image_file(&self) -> bool {
694 matches!(self, ContentPart::ImageFile(_))
695 }
696
697 pub fn is_file(&self) -> bool {
699 matches!(self, ContentPart::File(_))
700 }
701
702 pub fn content_type(&self) -> ContentType {
704 match self {
705 ContentPart::Text(_) => ContentType::Text,
706 ContentPart::Image(_) => ContentType::Image,
707 ContentPart::ImageFile(_) => ContentType::ImageFile,
708 ContentPart::File(_) => ContentType::File,
709 ContentPart::ToolCall(_) => ContentType::ToolCall,
710 ContentPart::ToolResult(_) => ContentType::ToolResult,
711 ContentPart::Reasoning(_) => ContentType::Reasoning,
712 ContentPart::ProviderOpaque(_) => ContentType::ProviderOpaque,
713 }
714 }
715
716 pub fn to_openai_format(&self) -> Option<serde_json::Value> {
721 match self {
722 ContentPart::Text(t) => Some(serde_json::json!({
723 "type": "text",
724 "text": t.text
725 })),
726 ContentPart::Image(img) => {
727 if let Some(url) = &img.url {
728 Some(serde_json::json!({
729 "type": "image_url",
730 "image_url": { "url": url }
731 }))
732 } else if let Some(b64) = &img.base64 {
733 let media_type = img.media_type.as_deref().unwrap_or("image/png");
734 Some(serde_json::json!({
735 "type": "image_url",
736 "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
737 }))
738 } else {
739 None
740 }
741 }
742 _ => None,
744 }
745 }
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
753#[cfg_attr(feature = "openapi", derive(ToSchema))]
754#[serde(tag = "type", rename_all = "snake_case")]
755pub enum InputContentPart {
756 Text(TextContentPart),
758 Image(ImageContentPart),
760 ImageFile(ImageFileContentPart),
762 File(FileContentPart),
764}
765
766impl From<InputContentPart> for ContentPart {
767 fn from(input: InputContentPart) -> Self {
768 match input {
769 InputContentPart::Text(t) => ContentPart::Text(t),
770 InputContentPart::Image(i) => ContentPart::Image(i),
771 InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
772 InputContentPart::File(f) => ContentPart::File(f),
773 }
774 }
775}
776
777impl InputContentPart {
778 pub fn text(text: impl Into<String>) -> Self {
780 InputContentPart::Text(TextContentPart::new(text))
781 }
782
783 pub fn image_url(url: impl Into<String>) -> Self {
785 InputContentPart::Image(ImageContentPart::from_url(url))
786 }
787
788 pub fn image_file(image_id: ImageId) -> Self {
790 InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
791 }
792
793 pub fn file(file_id: FileId) -> Self {
795 InputContentPart::File(FileContentPart::new(file_id))
796 }
797
798 pub fn as_text(&self) -> Option<&str> {
800 match self {
801 InputContentPart::Text(t) => Some(&t.text),
802 _ => None,
803 }
804 }
805
806 pub fn content_type(&self) -> ContentType {
808 match self {
809 InputContentPart::Text(_) => ContentType::Text,
810 InputContentPart::Image(_) => ContentType::Image,
811 InputContentPart::ImageFile(_) => ContentType::ImageFile,
812 InputContentPart::File(_) => ContentType::File,
813 }
814 }
815}
816
817impl RuntimeMessage {
818 pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
820 self.content.iter().filter_map(ContentPart::as_reasoning)
821 }
822
823 pub fn has_reasoning(&self) -> bool {
825 self.content.iter().any(ContentPart::is_reasoning)
826 }
827
828 pub fn reasoning_display_text(&self) -> Option<String> {
833 let joined = self
834 .reasoning_parts()
835 .filter_map(ReasoningContentPart::display_text)
836 .collect::<Vec<_>>()
837 .join("\n\n");
838 (!joined.is_empty()).then_some(joined)
839 }
840
841 pub fn into_public(mut self) -> Self {
844 self.content
845 .retain(|part| !matches!(part, ContentPart::ProviderOpaque(_)));
846 for part in &mut self.content {
847 if let ContentPart::Reasoning(r) = part {
848 *r = r.to_public();
849 }
850 }
851 self
852 }
853
854 pub fn with_id(mut self, id: MessageId) -> Self {
859 self.id = id;
860 self
861 }
862
863 pub fn user(content: impl Into<String>) -> Self {
865 Self {
866 id: MessageId::new(),
867 role: RuntimeMessageRole::User,
868 content: vec![ContentPart::text(content)],
869 phase: None,
870 phase_source: None,
871 controls: None,
872 metadata: None,
873 external_actor: None,
874 created_at: Utc::now(),
875 }
876 }
877
878 pub fn assistant(content: impl Into<String>) -> Self {
880 Self {
881 id: MessageId::new(),
882 role: RuntimeMessageRole::Agent,
883 content: vec![ContentPart::text(content)],
884 phase: None,
885 phase_source: None,
886 controls: None,
887 metadata: None,
888 external_actor: None,
889 created_at: Utc::now(),
890 }
891 }
892
893 pub fn assistant_with_tools(
899 content: impl Into<String>,
900 tool_calls: Vec<crate::tool_types::ToolCall>,
901 ) -> Self {
902 let text_content = content.into();
903 let mut parts = Vec::new();
904 if !text_content.is_empty() {
906 parts.push(ContentPart::text(text_content));
907 }
908 for tc in tool_calls {
909 parts.push(ContentPart::ToolCall(ToolCallContentPart {
910 native: None,
911 id: tc.id,
912 name: tc.name,
913 arguments: tc.arguments,
914 }));
915 }
916 Self {
917 id: MessageId::new(),
918 role: RuntimeMessageRole::Agent,
919 content: parts,
920 phase: None,
921 phase_source: None,
922 controls: None,
923 metadata: None,
924 external_actor: None,
925 created_at: Utc::now(),
926 }
927 }
928
929 pub fn system(content: impl Into<String>) -> Self {
931 Self {
932 id: MessageId::new(),
933 role: RuntimeMessageRole::System,
934 content: vec![ContentPart::text(content)],
935 phase: None,
936 phase_source: None,
937 controls: None,
938 metadata: None,
939 external_actor: None,
940 created_at: Utc::now(),
941 }
942 }
943
944 pub fn tool_result(
946 tool_call_id: impl Into<String>,
947 result: Option<serde_json::Value>,
948 error: Option<String>,
949 ) -> Self {
950 let tool_call_id = tool_call_id.into();
951 Self {
952 id: MessageId::new(),
953 role: RuntimeMessageRole::ToolResult,
954 content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
955 tool_call_id,
956 result,
957 error,
958 ))],
959 phase: None,
960 phase_source: None,
961 controls: None,
962 metadata: None,
963 external_actor: None,
964 created_at: Utc::now(),
965 }
966 }
967
968 pub fn tool_result_with_images(
974 tool_call_id: impl Into<String>,
975 result: Option<serde_json::Value>,
976 images: Vec<everruns_contracts::tool_types::ToolResultImage>,
977 ) -> Self {
978 let tool_call_id = tool_call_id.into();
979 let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
980 tool_call_id,
981 result,
982 None,
983 ))];
984 for img in images {
985 content.push(ContentPart::Image(ImageContentPart::from_base64(
986 img.base64,
987 img.media_type,
988 )));
989 }
990 Self {
991 id: MessageId::new(),
992 role: RuntimeMessageRole::ToolResult,
993 content,
994 phase: None,
995 phase_source: None,
996 controls: None,
997 metadata: None,
998 external_actor: None,
999 created_at: Utc::now(),
1000 }
1001 }
1002
1003 pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
1005 self.phase = Some(phase);
1006 self
1007 }
1008
1009 pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
1011 self.phase = Some(phase);
1012 self.phase_source = Some(source);
1013 self
1014 }
1015
1016 pub fn tool_call_id(&self) -> Option<&str> {
1020 self.content.iter().find_map(|p| match p {
1021 ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
1022 _ => None,
1023 })
1024 }
1025
1026 pub fn text(&self) -> Option<&str> {
1028 self.content.iter().find_map(|p| p.as_text())
1029 }
1030
1031 pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
1033 self.content
1034 .iter()
1035 .filter_map(|p| match p {
1036 ContentPart::ToolCall(tc) => Some(tc),
1037 _ => None,
1038 })
1039 .collect()
1040 }
1041
1042 pub fn has_tool_calls(&self) -> bool {
1044 self.content
1045 .iter()
1046 .any(|p| matches!(p, ContentPart::ToolCall(_)))
1047 }
1048
1049 pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
1051 self.content.iter().find_map(|p| match p {
1052 ContentPart::ToolResult(tr) => Some(tr),
1053 _ => None,
1054 })
1055 }
1056
1057 pub fn content_to_llm_string(&self) -> String {
1059 self.content
1060 .iter()
1061 .map(|part| match part {
1062 ContentPart::Text(t) => t.text.clone(),
1063 ContentPart::Reasoning(_) => String::new(),
1067 ContentPart::ProviderOpaque(_) => String::new(),
1068 ContentPart::Image(_) => "[Image]".to_string(),
1069 ContentPart::ImageFile(_) => "[Image File]".to_string(),
1070 ContentPart::File(part) => part
1071 .filename
1072 .clone()
1073 .map(|n| format!("[PDF File: {}]", n))
1074 .unwrap_or_else(|| "[PDF File]".to_string()),
1075 ContentPart::ToolCall(tc) => {
1076 format!(
1077 "Tool call: {} with arguments: {}",
1078 tc.name,
1079 serde_json::to_string(&tc.arguments).unwrap_or_default()
1080 )
1081 }
1082 ContentPart::ToolResult(tr) => {
1083 if let Some(err) = &tr.error {
1084 format!("Tool error: {}", err)
1085 } else if let Some(res) = &tr.result {
1086 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1087 } else {
1088 "{}".to_string()
1089 }
1090 }
1091 })
1092 .filter(|rendered| !rendered.is_empty())
1093 .collect::<Vec<_>>()
1094 .join("\n")
1095 }
1096
1097 pub fn to_openai_format(&self) -> serde_json::Value {
1106 let role = match self.role {
1107 RuntimeMessageRole::System => "system",
1108 RuntimeMessageRole::User => "user",
1109 RuntimeMessageRole::Agent => "assistant",
1110 RuntimeMessageRole::ToolResult => "tool",
1111 };
1112
1113 if self.role == RuntimeMessageRole::ToolResult {
1115 let tool_call_id = self.tool_call_id().unwrap_or("");
1116 let content = self
1117 .content
1118 .iter()
1119 .find_map(|p| match p {
1120 ContentPart::ToolResult(tr) => {
1121 if let Some(error) = &tr.error {
1122 Some(format!("Error: {}", error))
1123 } else if let Some(result) = &tr.result {
1124 Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
1125 } else {
1126 Some("{}".to_string())
1127 }
1128 }
1129 _ => None,
1130 })
1131 .unwrap_or_else(|| "{}".to_string());
1132
1133 return serde_json::json!({
1134 "role": role,
1135 "content": content,
1136 "tool_call_id": tool_call_id
1137 });
1138 }
1139
1140 if self.role == RuntimeMessageRole::Agent {
1142 let tool_calls: Vec<serde_json::Value> = self
1143 .content
1144 .iter()
1145 .filter_map(|p| match p {
1146 ContentPart::ToolCall(tc) => Some(serde_json::json!({
1147 "id": tc.id,
1148 "type": "function",
1149 "function": {
1150 "name": tc.name,
1151 "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
1152 }
1153 })),
1154 _ => None,
1155 })
1156 .collect();
1157
1158 let text_content: String = self
1159 .content
1160 .iter()
1161 .filter_map(|p| match p {
1162 ContentPart::Text(t) => Some(t.text.clone()),
1163 _ => None,
1164 })
1165 .collect::<Vec<_>>()
1166 .join("\n");
1167
1168 if tool_calls.is_empty() {
1169 return serde_json::json!({
1170 "role": role,
1171 "content": text_content
1172 });
1173 } else {
1174 let mut result = serde_json::json!({
1175 "role": role,
1176 "tool_calls": tool_calls
1177 });
1178 if !text_content.is_empty() {
1179 result["content"] = serde_json::json!(text_content);
1180 }
1181 return result;
1182 }
1183 }
1184
1185 let content = self.content_to_openai_format();
1187 serde_json::json!({
1188 "role": role,
1189 "content": content
1190 })
1191 }
1192
1193 fn content_to_openai_format(&self) -> serde_json::Value {
1195 if self.content.len() == 1
1197 && let ContentPart::Text(t) = &self.content[0]
1198 {
1199 return serde_json::json!(t.text);
1200 }
1201
1202 let parts: Vec<serde_json::Value> = self
1204 .content
1205 .iter()
1206 .filter_map(|part| part.to_openai_format())
1207 .collect();
1208
1209 if parts.is_empty() {
1210 return serde_json::json!("");
1211 }
1212
1213 if parts.len() == 1
1215 && let Some(text) = parts[0].get("text")
1216 {
1217 return text.clone();
1218 }
1219
1220 serde_json::json!(parts)
1221 }
1222}
1223
1224pub fn patch_dangling_tool_calls(messages: &[RuntimeMessage]) -> Vec<RuntimeMessage> {
1234 let mut result = Vec::new();
1235
1236 for (i, msg) in messages.iter().enumerate() {
1237 result.push(msg.clone());
1238
1239 if msg.role == RuntimeMessageRole::Agent && msg.has_tool_calls() {
1241 for tc in msg.tool_calls() {
1242 let has_result = messages[(i + 1)..].iter().any(|m| {
1244 m.role == RuntimeMessageRole::ToolResult && m.tool_call_id() == Some(&tc.id)
1245 });
1246
1247 if !has_result {
1248 result.push(RuntimeMessage::tool_result(
1249 &tc.id,
1250 None,
1251 Some(
1252 "cancelled - another message came in before it could be completed"
1253 .to_string(),
1254 ),
1255 ));
1256 }
1257 }
1258 }
1259 }
1260
1261 result
1262}
1263
1264#[cfg(test)]
1265#[path = "message_tests.rs"]
1266mod tests;