Skip to main content

everruns_core/
message.rs

1// RuntimeMessage is a DB-agnostic message type that represents
2// a single message in the conversation history.
3//
4// Content is stored as Vec<ContentPart> for unified representation
5// across storage and runtime layers.
6
7use 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/// Message role in the conversation
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(ToSchema))]
22// The Rust name is the published schema name. The REST API exposes only `user`
23// and `agent` (`api::messages::MessageRole`); publishing this four-variant
24// runtime enum as plain `MessageRole` made clients model roles the API never
25// returns.
26#[serde(rename_all = "snake_case")]
27pub enum RuntimeMessageRole {
28    /// System message (instructions)
29    System,
30    /// User message
31    User,
32    /// Agent response (may contain tool calls in content)
33    Agent,
34    /// Tool execution result
35    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            // Accept both "agent" and legacy "assistant"
55            "agent" | "assistant" => RuntimeMessageRole::Agent,
56            "tool_result" => RuntimeMessageRole::ToolResult,
57            _ => RuntimeMessageRole::User,
58        }
59    }
60}
61
62// ============================================
63// External Actor (channel-agnostic user identity)
64// ============================================
65
66/// External actor identity for messages originating from external channels
67/// (Slack, Discord, Teams, etc.).
68///
69/// Channel adapters populate this to identify the sender without coupling
70/// core logic to any specific channel. The ReasonAtom uses this to prefix
71/// user messages so the LLM knows who is speaking.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[cfg_attr(feature = "openapi", derive(ToSchema))]
74pub struct ExternalActor {
75    /// Opaque actor identifier from the source channel (e.g. Slack user ID "U0123456789")
76    pub actor_id: String,
77    /// Resolved display name (e.g. "Alice"). Falls back to actor_id if absent.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub actor_name: Option<String>,
80    /// Source channel identifier (e.g. "slack", "discord")
81    pub source: String,
82    /// Channel-specific metadata (e.g. team_id, channel_id)
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub metadata: Option<std::collections::HashMap<String, String>>,
85}
86
87impl ExternalActor {
88    /// Human-readable label: display name if available, otherwise actor_id.
89    pub fn display_label(&self) -> &str {
90        self.actor_name.as_deref().unwrap_or(&self.actor_id)
91    }
92}
93
94// ============================================
95// Controls (runtime options for message processing)
96// ============================================
97
98/// Reasoning configuration for the model
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101pub struct ReasoningConfig {
102    /// Effort level for reasoning.
103    ///
104    /// Typed rather than free-form: the effort taxonomy is closed, and each
105    /// driver previously re-parsed the string with its own case handling, which
106    /// let `minimal` silently mean "no reasoning" on budget-based models.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub effort: Option<everruns_contracts::model::ReasoningEffort>,
109}
110
111/// Runtime controls for message processing
112#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
113#[cfg_attr(feature = "openapi", derive(ToSchema))]
114pub struct Controls {
115    /// Model ID to use for this message (format: model_{32-hex}).
116    /// Overrides session and agent model settings.
117    #[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    /// Locale override for this message turn (BCP 47, e.g. `uk-UA`).
122    /// Overrides the session locale for backend-authored strings and prompts.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub locale: Option<String>,
125
126    /// Reasoning configuration
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub reasoning: Option<ReasoningConfig>,
129
130    /// Speed (service tier) for this message turn: "flex", "default",
131    /// "priority", "fast" (OpenAI's newer name for priority) or "ultrafast".
132    /// Only sent when the model's profile lists the tier (OpenAI
133    /// `service_tier`); otherwise the runtime drops it.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub speed: Option<String>,
136
137    /// Verbosity for this message turn: "low", "medium", or "high". Only sent
138    /// to providers whose model profile advertises a verbosity config (OpenAI
139    /// `verbosity`).
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub verbosity: Option<String>,
142
143    /// Error disclosure override for this turn: "generic", "standard", or
144    /// "detailed". Clamped to at most the mode allowed by the agent's
145    /// `error_disclosure` capability (capability absent => "standard"), so a
146    /// client can narrow but never widen disclosure.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub error_disclosure: Option<String>,
149
150    /// Generic client hints — arbitrary key-value pairs declared by the client.
151    /// Session-level defaults are set at session creation; per-message values
152    /// override session hints key-by-key (shallow merge).
153    ///
154    /// Examples: `{"setup_connection": true, "rich_media": true}`
155    #[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    /// Resolve effective hints by shallow-merging session-level defaults with
162    /// per-message overrides. Per-message hints take precedence key-by-key.
163    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/// A message in the conversation
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(ToSchema))]
183// The canonical runtime/event message, and the Rust name is the published
184// schema name. Distinct from the REST resource `api::messages::Message` (which
185// adds `session_id` and `sequence`) and from the request-shaped
186// `everruns_contracts::driver_registry::Message` a driver sends upstream; both
187// of the first two once claimed plain `Message` in one OpenAPI document, so
188// generated clients saw whichever won.
189pub struct RuntimeMessage {
190    /// Unique message ID (format: message_{32-hex})
191    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
192    pub id: MessageId,
193
194    /// Message role
195    pub role: RuntimeMessageRole,
196
197    /// Message content as array of content parts (text, images, tool calls, tool results)
198    pub content: Vec<ContentPart>,
199
200    /// Execution phase for this message.
201    ///
202    /// Helps LLMs distinguish between intermediate working commentary and completed
203    /// answers in multi-step tool-calling flows. Only set on agent (assistant) messages.
204    /// Providers with native phase support (OpenAI GPT-5.x) send this value in the API
205    /// request; others derive it from state but don't send it to the provider.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub phase: Option<ExecutionPhase>,
208
209    /// Whether [`Self::phase`] was reported by the provider or inferred from
210    /// tool-call presence. A derived phase carries no information beyond
211    /// "this message called tools", so consumers that need a real
212    /// classification must be able to tell the two apart.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub phase_source: Option<PhaseSource>,
215
216    /// Runtime controls (model, reasoning, etc.)
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub controls: Option<Controls>,
219
220    /// Message-level metadata
221    #[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    /// External actor identity (for messages from external channels like Slack)
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub external_actor: Option<ExternalActor>,
228
229    /// Timestamp when the message was created
230    pub created_at: DateTime<Utc>,
231}
232
233// ============================================
234// Content Type Enum
235// ============================================
236
237/// Content type discriminator
238#[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    /// Generic file attachment (for example a PDF).
246    File,
247    ToolCall,
248    ToolResult,
249    Reasoning,
250    /// Provider-native content retained only for internal replay.
251    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// ============================================
284// Content Part Structs
285// ============================================
286
287/// Text content part
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
289#[cfg_attr(feature = "openapi", derive(ToSchema))]
290pub struct TextContentPart {
291    pub text: String,
292    /// Claim-level citations attached to spans of `text`.
293    ///
294    /// The narrow render contract shared by all citation capabilities (see
295    /// `knowledge/runtime-resources/citations.md`). Empty for non-cited text, so the wire shape of
296    /// existing messages is unchanged.
297    #[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    /// Attach citation annotations, replacing any existing ones.
310    pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
311        self.annotations = annotations;
312        self
313    }
314}
315
316/// A claim-level citation attached to a span of generated text.
317///
318/// The single shared type across every citation capability: a text span linked
319/// to a source. Producers agree only on this render contract — each capability
320/// keeps its own richer representation (e.g. `KnowledgeIndexCitation`) and maps
321/// into this envelope at emit time. See `knowledge/runtime-resources/citations.md`.
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
323#[cfg_attr(feature = "openapi", derive(ToSchema))]
324pub struct TextAnnotation {
325    /// 0-indexed start char offset into the enclosing `TextContentPart.text`.
326    #[cfg_attr(feature = "openapi", schema(example = 0))]
327    pub start: usize,
328    /// Exclusive end char offset.
329    #[cfg_attr(feature = "openapi", schema(example = 19))]
330    pub end: usize,
331    /// Capability id that produced this annotation (e.g. `citation_retrieval`).
332    /// Lets the UI and evals attribute and filter each citation by feed.
333    #[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
334    pub origin: String,
335    /// The cited source.
336    pub source: AnnotationSource,
337    /// Opaque producer id (e.g. `kchk_…`, `kbe_…`, a URL hash). Not interpreted
338    /// by the render contract.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    #[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
341    pub external_id: Option<String>,
342    /// Verification verdict, filled by the `citation_verification` capability.
343    /// Absent means unverified (not "unsupported").
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub verified: Option<VerificationVerdict>,
346}
347
348/// The source a [`TextAnnotation`] points to.
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
350#[cfg_attr(feature = "openapi", derive(ToSchema))]
351pub struct AnnotationSource {
352    /// Stable, linkable locator (e.g. `github://owner/repo@main/docs/x.md` or an
353    /// `https://` URL).
354    #[cfg_attr(
355        feature = "openapi",
356        schema(example = "github://owner/repo@main/docs/x.md")
357    )]
358    pub uri: String,
359    /// Human-readable source title, when known.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    #[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
362    pub title: Option<String>,
363    /// Trimmed passage that backs the claim. Display-only; never relied on for
364    /// prompt reconstruction.
365    #[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    /// Provenance within the document (line / char / page / block ranges),
372    /// reusing the retrieval `location` JSONB shape.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub location: Option<serde_json::Value>,
375}
376
377/// Outcome of citation verification (see the `citation_verification` capability).
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379#[cfg_attr(feature = "openapi", derive(ToSchema))]
380pub struct VerificationVerdict {
381    /// Whether the cited source supports the claim.
382    pub status: VerificationStatus,
383    /// Entailment confidence in `[0, 1]`, when the verifier produced one.
384    #[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/// Whether a cited source entails the claim it is attached to.
390#[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    /// The source supports the claim.
396    Entailed,
397    /// The source does not support the claim.
398    Unsupported,
399    /// The verifier could not decide.
400    Uncertain,
401}
402
403/// Image content part (base64 or URL)
404#[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/// Image file content part (reference to uploaded image)
434///
435/// This is used for images uploaded via the /images API.
436/// The image data is stored separately and referenced by ID.
437/// Note: Currently filtered out before sending to LLM.
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
439#[cfg_attr(feature = "openapi", derive(ToSchema))]
440pub struct ImageFileContentPart {
441    /// ID of the uploaded image (format: img_{32-hex})
442    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
443    pub image_id: ImageId,
444    /// Original filename (for display)
445    #[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/// File content part (reference to an uploaded file, e.g. a PDF)
466///
467/// This is used for files uploaded via the /files API.
468/// The file data is stored separately and referenced by ID.
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
470#[cfg_attr(feature = "openapi", derive(ToSchema))]
471pub struct FileContentPart {
472    /// ID of the uploaded file (format: file_{32-hex})
473    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "file_01933b5a00007000800000000000001"))]
474    pub file_id: FileId,
475    /// Original filename (for display and provider file parts)
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub filename: Option<String>,
478}
479
480impl FileContentPart {
481    /// Create a file content part that references an already-stored file.
482    pub fn new(file_id: FileId) -> Self {
483        Self {
484            file_id,
485            filename: None,
486        }
487    }
488
489    /// Create a file content part with its original filename attached.
490    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/// Tool call content part (assistant requesting tool execution)
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
500#[cfg_attr(feature = "openapi", derive(ToSchema))]
501pub struct ToolCallContentPart {
502    /// Original native call, including raw custom input and async metadata.
503    #[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    /// Validate and retain a native call alongside its portable tool arguments.
512    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/// Tool result content part (result of tool execution)
545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
546#[cfg_attr(feature = "openapi", derive(ToSchema))]
547pub struct ToolResultContentPart {
548    /// ID of the tool call this result corresponds to
549    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// ============================================
587// Content Part Enums
588// ============================================
589
590/// A part of message content - can be text, image, image_file, tool_call, or tool_result
591///
592/// This is the canonical content part type used across the system.
593/// API layer enables the "openapi" feature to add ToSchema derive.
594#[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 content
600    Text(TextContentPart),
601    /// Image content (base64 or URL)
602    Image(ImageContentPart),
603    /// Image file content (reference to uploaded image by ID)
604    ImageFile(ImageFileContentPart),
605    /// File content (reference to uploaded file, e.g. PDF, by ID)
606    File(FileContentPart),
607    /// Tool call content (assistant requesting tool execution)
608    ToolCall(ToolCallContentPart),
609    /// Tool result content (result of tool execution)
610    ToolResult(ToolResultContentPart),
611    /// Provider reasoning artifact, ordered against the text and tool calls it
612    /// was interleaved with.
613    Reasoning(ReasoningContentPart),
614    /// Provider-native assistant content retained only for internal replay.
615    ProviderOpaque(ProviderOpaqueContent),
616}
617
618impl ContentPart {
619    /// Create a text content part
620    pub fn text(text: impl Into<String>) -> Self {
621        ContentPart::Text(TextContentPart::new(text))
622    }
623
624    /// Convert a JSON tool result into text without JSON-quoting string values.
625    /// Structured values retain their JSON representation for transport and details views.
626    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    /// Create an image content part from URL
634    pub fn image_url(url: impl Into<String>) -> Self {
635        ContentPart::Image(ImageContentPart::from_url(url))
636    }
637
638    /// Create an image file content part (reference to uploaded image)
639    pub fn image_file(image_id: ImageId) -> Self {
640        ContentPart::ImageFile(ImageFileContentPart::new(image_id))
641    }
642
643    /// Create a generic file content part (reference to an uploaded file).
644    pub fn file(file_id: FileId) -> Self {
645        ContentPart::File(FileContentPart::new(file_id))
646    }
647
648    /// Create a tool call content part
649    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    /// Create a tool result content part
658    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    /// Create a reasoning content part
667    pub fn reasoning(part: ReasoningContentPart) -> Self {
668        ContentPart::Reasoning(part)
669    }
670
671    /// Get the reasoning artifact if this is a reasoning part
672    pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
673        match self {
674            ContentPart::Reasoning(r) => Some(r),
675            _ => None,
676        }
677    }
678
679    /// Whether this part is a reasoning artifact.
680    pub fn is_reasoning(&self) -> bool {
681        matches!(self, ContentPart::Reasoning(_))
682    }
683
684    /// Get text if this is a text part
685    pub fn as_text(&self) -> Option<&str> {
686        match self {
687            ContentPart::Text(t) => Some(&t.text),
688            _ => None,
689        }
690    }
691
692    /// Check if this is an ImageFile part
693    pub fn is_image_file(&self) -> bool {
694        matches!(self, ContentPart::ImageFile(_))
695    }
696
697    /// Returns true when this is a generic file part.
698    pub fn is_file(&self) -> bool {
699        matches!(self, ContentPart::File(_))
700    }
701
702    /// Get the content type
703    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    /// Convert content part to OpenAI-compatible format
717    ///
718    /// Returns `None` for content types that aren't valid in user/system messages
719    /// (ImageFile, ToolCall, ToolResult are handled at message level).
720    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            // ImageFile, ToolCall, ToolResult handled at message level
743            _ => None,
744        }
745    }
746}
747
748/// Input content part - text, image, and image_file (for user input)
749///
750/// This is a subset of ContentPart that users can send.
751/// Tool calls and results are system-generated.
752#[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 content
757    Text(TextContentPart),
758    /// Image content (base64 or URL)
759    Image(ImageContentPart),
760    /// Image file content (reference to uploaded image by ID)
761    ImageFile(ImageFileContentPart),
762    /// File content (reference to uploaded file, e.g. PDF, by ID)
763    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    /// Create a text content part
779    pub fn text(text: impl Into<String>) -> Self {
780        InputContentPart::Text(TextContentPart::new(text))
781    }
782
783    /// Create an image content part from URL
784    pub fn image_url(url: impl Into<String>) -> Self {
785        InputContentPart::Image(ImageContentPart::from_url(url))
786    }
787
788    /// Create an image file content part (reference to uploaded image)
789    pub fn image_file(image_id: ImageId) -> Self {
790        InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
791    }
792
793    /// Create a generic file input part (reference to an uploaded file).
794    pub fn file(file_id: FileId) -> Self {
795        InputContentPart::File(FileContentPart::new(file_id))
796    }
797
798    /// Get text content if this is a Text part
799    pub fn as_text(&self) -> Option<&str> {
800        match self {
801            InputContentPart::Text(t) => Some(&t.text),
802            _ => None,
803        }
804    }
805
806    /// Get the content type
807    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    /// Reasoning artifacts carried by this message, in emission order.
819    pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
820        self.content.iter().filter_map(ContentPart::as_reasoning)
821    }
822
823    /// Whether this message carries any provider reasoning artifact.
824    pub fn has_reasoning(&self) -> bool {
825        self.content.iter().any(ContentPart::is_reasoning)
826    }
827
828    /// Readable reasoning across every artifact, joined for display.
829    ///
830    /// Display only. Replay must walk [`RuntimeMessage::reasoning_parts`] so each
831    /// artifact keeps its own signature and position.
832    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    /// Replace every reasoning part with its publishable projection, dropping
842    /// opaque provider replay state. Used at API boundaries.
843    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    /// Override the generated message id.
855    ///
856    /// Streaming producers use this to allocate a public id before emitting
857    /// `output.message.started`, then reuse it on the completed message.
858    pub fn with_id(mut self, id: MessageId) -> Self {
859        self.id = id;
860        self
861    }
862
863    /// Create a new user message
864    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    /// Create a new assistant message
879    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    /// Create a new assistant message with tool calls
894    ///
895    /// Tool calls are stored as ContentPart::ToolCall in the content array
896    /// alongside the text content. Empty text content is omitted to avoid
897    /// LLM API errors (e.g., Anthropic requires non-empty text blocks).
898    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        // Only include text part if non-empty
905        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    /// Create a new system message
930    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    /// Create a tool result message
945    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    /// Create a tool result message with images.
969    ///
970    /// Images are included as `ContentPart::Image` alongside the `ToolResult` part.
971    /// When converted to the provider `Message`, images become native image content blocks
972    /// that the LLM can see visually (not just stringified base64).
973    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    /// Set the execution phase on this message and return self.
1004    pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
1005        self.phase = Some(phase);
1006        self
1007    }
1008
1009    /// Set the phase together with where it came from.
1010    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    /// Get the tool_call_id from a tool result message
1017    ///
1018    /// Returns the tool_call_id from the first ToolResult content part, if any.
1019    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    /// Get first text content from the message
1027    pub fn text(&self) -> Option<&str> {
1028        self.content.iter().find_map(|p| p.as_text())
1029    }
1030
1031    /// Get all tool calls from the message content
1032    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    /// Check if this message has tool calls
1043    pub fn has_tool_calls(&self) -> bool {
1044        self.content
1045            .iter()
1046            .any(|p| matches!(p, ContentPart::ToolCall(_)))
1047    }
1048
1049    /// Get the first tool result from the message content
1050    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    /// Convert content to LLM-compatible string representation
1058    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                // Reasoning is replayed as provider-native artifacts on
1064                // the provider `Message::reasoning`; it must never be flattened into
1065                // prompt text. Filtered out below.
1066                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    /// Convert message to OpenAI-compatible format
1098    ///
1099    /// Transforms internal message format to OpenAI API format:
1100    /// - `agent` role → `assistant`
1101    /// - `tool_result` role → `tool` (with tool_call_id at message level)
1102    /// - Tool calls formatted as `{id, type: "function", function: {name, arguments}}`
1103    ///
1104    /// Used by observability backends (e.g., Braintrust) that expect OpenAI format.
1105    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        // Handle tool result messages (need tool_call_id at message level)
1114        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        // Handle assistant messages with tool calls
1141        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        // For system/user messages, convert content parts
1186        let content = self.content_to_openai_format();
1187        serde_json::json!({
1188            "role": role,
1189            "content": content
1190        })
1191    }
1192
1193    /// Convert content parts to OpenAI-compatible format
1194    fn content_to_openai_format(&self) -> serde_json::Value {
1195        // Single text content → string
1196        if self.content.len() == 1
1197            && let ContentPart::Text(t) = &self.content[0]
1198        {
1199            return serde_json::json!(t.text);
1200        }
1201
1202        // Convert each content part
1203        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        // Single text part after filtering → string
1214        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
1224/// Patch dangling tool calls by adding synthetic "cancelled" results.
1225///
1226/// This ensures every tool call has a corresponding tool result,
1227/// preventing LLM API errors (e.g., OpenAI requires every tool_call to have a result).
1228///
1229/// This is the simple, store-free patcher used by out-of-band completions
1230/// (see `crate::command_host`). The main reason path uses the durable-store-aware
1231/// the execution kernel's transcript-repair path instead (EVE-533),
1232/// which can replay settled results rather than synthesizing cancellations.
1233pub 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        // After an assistant message with tool calls, add cancelled results for any missing ones
1240        if msg.role == RuntimeMessageRole::Agent && msg.has_tool_calls() {
1241            for tc in msg.tool_calls() {
1242                // Look for a matching tool result in ALL subsequent messages
1243                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;