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;