Skip to main content

everruns_contracts/
message.rs

1//! The provider-agnostic message a driver sends, and what it is made of.
2//!
3//! [`Message`] is the one message shape every driver converts from, whatever
4//! its vendor's wire format. Callers holding OpenAI chat JSON convert with
5//! [`openai_wire`](crate::openai_wire) rather than building these by hand.
6
7use crate::tool_types::ToolCall;
8use serde::{Deserialize, Serialize};
9
10/// Provider-native assistant content retained for lossless replay.
11///
12/// Drivers use this only when a provider requires its response content to be
13/// sent back without reconstruction. The portable text, reasoning, and tool
14/// call fields remain the fallback for other providers.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
17pub struct ProviderOpaqueContent {
18    /// Provider that owns and can replay this content.
19    pub provider: String,
20    /// Original provider-native assistant content.
21    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
22    pub content: serde_json::Value,
23}
24
25impl ProviderOpaqueContent {
26    pub fn new(provider: impl Into<String>, content: serde_json::Value) -> Self {
27        Self {
28            provider: provider.into(),
29            content,
30        }
31    }
32}
33
34/// Internal marker that asks a capable driver to expire a system reminder at
35/// the next user message. Drivers must remove it before serialization.
36pub const TURN_SCOPED_SYSTEM_MARKER: &str = "\u{0}everruns:turn-scoped-system\u{0}";
37
38/// Message format for LLM calls (provider-agnostic): the request-shaped view a
39/// driver turns into provider wire format. Distinct from the lossless stored
40/// runtime message, which the runtime conversion layer maps here.
41#[derive(Debug, Clone)]
42pub struct Message {
43    /// Provider-native call identities, retained alongside portable fallbacks.
44    pub native_tool_calls: Vec<crate::native_async::NativeToolCall>,
45    pub role: MessageRole,
46    pub content: MessageContent,
47    pub tool_calls: Option<Vec<ToolCall>>,
48    pub tool_call_id: Option<String>,
49    /// Execution phase for assistant messages.
50    /// Helps models distinguish between intermediate working commentary (`Commentary`)
51    /// and completed answers (`FinalAnswer`) in multi-step tool-calling flows.
52    /// Only set on assistant messages. Must be preserved when replaying conversation history.
53    pub phase: Option<crate::execution_phase::ExecutionPhase>,
54    /// Provider reasoning artifacts for this assistant turn, in emission order.
55    ///
56    /// Drivers replay these verbatim in the position the provider issued them:
57    /// each keeps its own signature, id and encrypted payload, so interleaved
58    /// thinking and per-call thought signatures survive a round trip. Empty for
59    /// messages without reasoning.
60    pub reasoning: Vec<crate::reasoning::ReasoningContentPart>,
61    /// Astra effort transition immediately before this message. Other protocols
62    /// ignore it; it is never rendered as conversation text.
63    pub configuration_update: Option<crate::model::ReasoningEffort>,
64}
65
66impl Message {
67    /// Create a message with text content
68    pub fn text(role: MessageRole, content: impl Into<String>) -> Self {
69        Self {
70            native_tool_calls: Vec::new(),
71            role,
72            content: MessageContent::Text(content.into()),
73            tool_calls: None,
74            tool_call_id: None,
75            phase: None,
76            reasoning: Vec::new(),
77            configuration_update: None,
78        }
79    }
80
81    /// Create a message with content parts (text, images, audio)
82    pub fn parts(role: MessageRole, parts: Vec<LlmContentPart>) -> Self {
83        Self {
84            native_tool_calls: Vec::new(),
85            role,
86            content: MessageContent::Parts(parts),
87            tool_calls: None,
88            tool_call_id: None,
89            phase: None,
90            reasoning: Vec::new(),
91            configuration_update: None,
92        }
93    }
94
95    /// Get content as plain text string (for simple cases)
96    pub fn content_as_text(&self) -> String {
97        self.content.to_text()
98    }
99
100    /// Prepend a prefix to the first text content.
101    ///
102    /// Used by ReasonAtom to inject external actor identity (e.g. `"[Alice] "`)
103    /// into user messages from external channels.
104    pub fn prepend_text_prefix(&mut self, prefix: &str) {
105        match &mut self.content {
106            MessageContent::Text(text) => {
107                *text = format!("{}{}", prefix, text);
108            }
109            MessageContent::Parts(parts) => {
110                for part in parts.iter_mut() {
111                    if let LlmContentPart::Text { text } = part {
112                        *text = format!("{}{}", prefix, text);
113                        return;
114                    }
115                }
116                // No text part found — prepend one
117                parts.insert(
118                    0,
119                    LlmContentPart::Text {
120                        text: prefix.to_string(),
121                    },
122                );
123            }
124        }
125    }
126
127    /// Mark this system message as scoped to the current model turn.
128    pub fn mark_turn_scoped_system(&mut self) {
129        debug_assert_eq!(self.role, MessageRole::System);
130        self.prepend_text_prefix(TURN_SCOPED_SYSTEM_MARKER);
131    }
132}
133
134/// Fold every `System`-role message into a single string, joined in order with
135/// blank lines.
136///
137/// Multiple system messages legitimately occur in one request: the agent system
138/// prompt plus, e.g., `infinity_context`'s hidden-history notice or
139/// `compaction`'s `[CONVERSATION_SUMMARY]`. Drivers that map the system role into
140/// a dedicated top-level field (Anthropic `system`, Gemini `system_instruction`,
141/// OpenResponses `instructions`) must accumulate rather than overwrite — otherwise
142/// the real agent system prompt is silently dropped and only the last notice
143/// survives. Returns `None` when there are no system messages.
144pub fn fold_system_messages(messages: &[Message]) -> Option<String> {
145    let mut system: Option<String> = None;
146    for msg in messages {
147        if msg.role == MessageRole::System {
148            let text = msg.content.to_text();
149            system = Some(match system.take() {
150                Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
151                _ => text,
152            });
153        }
154    }
155    system
156}
157
158/// Message content - either a simple string or array of content parts
159#[derive(Debug, Clone)]
160pub enum MessageContent {
161    /// Simple text content
162    Text(String),
163    /// Array of content parts (text, images, audio)
164    Parts(Vec<LlmContentPart>),
165}
166
167impl MessageContent {
168    /// Convert to plain text (concatenates text parts, ignores media)
169    pub fn to_text(&self) -> String {
170        match self {
171            MessageContent::Text(s) => s.clone(),
172            MessageContent::Parts(parts) => parts
173                .iter()
174                .filter_map(|p| match p {
175                    LlmContentPart::Text { text } => Some(text.clone()),
176                    _ => None,
177                })
178                .collect::<Vec<_>>()
179                .join(""),
180        }
181    }
182
183    /// Check if content is simple text
184    pub fn is_text(&self) -> bool {
185        matches!(self, MessageContent::Text(_))
186    }
187
188    /// Check if content has multiple parts
189    pub fn is_parts(&self) -> bool {
190        matches!(self, MessageContent::Parts(_))
191    }
192}
193
194impl From<String> for MessageContent {
195    fn from(s: String) -> Self {
196        MessageContent::Text(s)
197    }
198}
199
200impl From<&str> for MessageContent {
201    fn from(s: &str) -> Self {
202        MessageContent::Text(s.to_string())
203    }
204}
205
206/// A single content part within a message
207///
208/// `#[non_exhaustive]` for the same reason as [`LlmStreamEvent`](crate::driver_registry::LlmStreamEvent): new content
209/// kinds are additive and must not break downstream `match`es.
210#[derive(Debug, Clone, PartialEq, Eq)]
211#[non_exhaustive]
212pub enum LlmContentPart {
213    /// Text content
214    Text { text: String },
215    /// Image content (base64 data URL or HTTP URL)
216    Image { url: String },
217    /// Audio content (base64 data URL)
218    Audio { url: String },
219    /// File content, e.g. a PDF document (base64 data URL or file URL)
220    File {
221        url: String,
222        filename: Option<String>,
223    },
224    /// Provider-native assistant content used only by the issuing provider.
225    ProviderOpaque(ProviderOpaqueContent),
226}
227
228impl LlmContentPart {
229    /// Create a text content part
230    pub fn text(text: impl Into<String>) -> Self {
231        LlmContentPart::Text { text: text.into() }
232    }
233
234    /// Create an image content part from URL (can be data URL or HTTP URL)
235    pub fn image(url: impl Into<String>) -> Self {
236        LlmContentPart::Image { url: url.into() }
237    }
238
239    /// Create an audio content part from URL (typically a data URL)
240    pub fn audio(url: impl Into<String>) -> Self {
241        LlmContentPart::Audio { url: url.into() }
242    }
243
244    /// Create a file content part from URL (typically a data URL)
245    pub fn file(url: impl Into<String>, filename: Option<String>) -> Self {
246        LlmContentPart::File {
247            url: url.into(),
248            filename,
249        }
250    }
251}
252
253/// Message role for LLM calls
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum MessageRole {
256    System,
257    User,
258    Assistant,
259    Tool,
260}
261
262// The names these types had before 0.31. Aliases rather than removals so an
263// embedder whose own code defines a `Message` upgrades with a warning instead
264// of a compile error.
265
266/// Pre-0.31 name of [`Message`].
267///
268/// Deprecated: an alias, so existing code compiles with a warning. To migrate
269/// without colliding with an application's own `Message`, import under a
270/// local name:
271///
272/// ```
273/// use everruns_contracts::{Message as LlmMessage, MessageRole};
274///
275/// let message = LlmMessage::text(MessageRole::User, "hi");
276/// # let _ = message;
277/// ```
278#[deprecated(since = "0.32.0", note = "renamed to `Message`")]
279pub type LlmMessage = Message;
280/// Pre-0.31 name of [`MessageContent`].
281#[deprecated(since = "0.32.0", note = "renamed to `MessageContent`")]
282pub type LlmMessageContent = MessageContent;
283/// Pre-0.31 name of [`MessageRole`].
284#[deprecated(since = "0.32.0", note = "renamed to `MessageRole`")]
285pub type LlmMessageRole = MessageRole;