molo_core/message.rs
1//! Conversation messages.
2//!
3//! The message data model implements serde serialization / deserialization:
4//! session persistence and cross-process transport (tool definitions and
5//! messages) serialize these types directly, no manual mapping needed.
6//!
7//! A full conversation history is expressed as a [`Message`] sequence; each
8//! message consists of [content blocks](ContentBlock) (text / images, or
9//! vendor-shaped blocks passed through verbatim via [`ContentBlock::Wire`]).
10//! The structured entry point of the content model is [`ContentBlock`] — the
11//! shape of [`Message`] stays unchanged, and consumers just match the new
12//! variant.
13
14use serde::{Deserialize, Serialize};
15
16/// A tool call requested by the model.
17///
18/// The model requests a tool call via the `tool_calls` field of
19/// [`Message::Assistant`]; after the agent loop executes it, the result is
20/// passed back as a [`Message::ToolResult`] message right after, paired
21/// with this call by `id` (which disambiguates multiple calls to the
22/// same-named tool in one turn).
23///
24/// # Example
25///
26/// Construct a tool request and pair it with the returned execution result
27/// (in real flows the request is generated by the model and passed back
28/// automatically after the loop executes it):
29///
30/// ```
31/// # extern crate molo_core as molo;
32/// use molo::{Message, ToolCall};
33///
34/// let request = Message::Assistant {
35/// content: String::new(),
36/// reasoning: None,
37/// tool_calls: vec![ToolCall {
38/// id: "call_1".into(),
39/// name: "weather".into(),
40/// arguments: r#"{"city":"Beijing"}"#.into(),
41/// }],
42/// };
43/// let result = Message::tool_result("call_1", "Sunny, 23°C");
44///
45/// let Message::Assistant { tool_calls, .. } = &request else {
46/// panic!("expected an assistant message");
47/// };
48/// assert_eq!(tool_calls[0].id, "call_1");
49/// assert_eq!(result, Message::tool_result("call_1", "Sunny, 23°C"));
50/// ```
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub struct ToolCall {
53 /// Unique id of this call; the execution result is paired with it via
54 /// the `id` of [`Message::ToolResult`].
55 pub id: String,
56 /// Tool name, matching the `name` of the tool definition
57 /// [`Tool::schema`](crate::Tool::schema).
58 pub name: String,
59 /// Model-generated arguments (JSON text), parsed by the agent loop and
60 /// handed to the tool for execution.
61 pub arguments: String,
62}
63
64/// A single message in a conversation.
65///
66/// This type carries context uniformly between Provider / Memory / Agent:
67/// what Memory stores and returns, and what Provider sends and receives,
68/// are all [`Message`] sequences; Provider implementations map them to the
69/// vendor's wire format.
70///
71/// Note: the model may request several tools in one turn, and these requests
72/// **must stay in a single [`Message::Assistant`] message** — splitting them
73/// across messages breaks vendor wire validation (some vendors require tool
74/// results to immediately follow the assistant message carrying them, e.g.
75/// DeepSeek).
76///
77/// # Example
78///
79/// Organize a conversation history with the convenience constructors (in
80/// real scenarios the agent loop and Memory do this automatically):
81///
82/// ```
83/// # extern crate molo_core as molo;
84/// use molo::Message;
85///
86/// let history = vec![
87/// Message::system("You are a helpful assistant"),
88/// Message::user("How is the weather in Beijing today?"),
89/// Message::assistant("Let me check the weather."),
90/// Message::tool_result("call_1", "Sunny, 23°C"),
91/// ];
92///
93/// assert_eq!(history.len(), 4);
94/// assert_eq!(history[1], Message::user("How is the weather in Beijing today?"));
95/// ```
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
97pub enum Message {
98 /// System instruction describing the agent's role and behavior
99 /// constraints.
100 System(String),
101 /// User input, made of content blocks (text / images / vendor-shaped
102 /// pass-through blocks).
103 User(Vec<ContentBlock>),
104 /// Assistant reply: text + reasoning + requested tool calls (multiple
105 /// requests in one turn stay together).
106 Assistant {
107 /// Reply text.
108 content: String,
109 /// The model's reasoning; provided by thinking models (e.g.
110 /// DeepSeek / Qwen3), `None` for other models.
111 ///
112 /// This field is a vendor extension and **must be passed back
113 /// verbatim when sending the conversation history**, otherwise the
114 /// API rejects the request (e.g. DeepSeek reports
115 /// "The reasoning_content in the thinking mode must be passed back to the API.").
116 reasoning: Option<String>,
117 /// Tools requested by the model with this reply; execution results
118 /// are passed back right after as [`Message::ToolResult`] messages.
119 tool_calls: Vec<ToolCall>,
120 },
121 /// Tool execution result, passed back to the model.
122 ToolResult {
123 /// The id corresponding to [`ToolCall::id`].
124 id: String,
125 /// Text of the execution result (on failure, the error text; the
126 /// model decides what to do next).
127 content: String,
128 },
129}
130
131/// A content block within a message.
132///
133/// The structured entry point of the content model: text and image blocks,
134/// plus vendor-shaped pass-through blocks ([`Wire`](ContentBlock::Wire)).
135///
136/// # Example
137///
138/// ```
139/// # extern crate molo_core as molo;
140/// use molo::{ContentBlock, ImageContent, Message};
141///
142/// let msg = Message::user_blocks(vec![
143/// ContentBlock::Text("What is in this picture?".into()),
144/// ContentBlock::Image(ImageContent::new("image/png", vec![0x89, b'P', b'N', b'G'])),
145/// ]);
146///
147/// assert_eq!(msg, Message::user_blocks(vec![
148/// ContentBlock::Text("What is in this picture?".into()),
149/// ContentBlock::Image(ImageContent::new("image/png", vec![0x89, b'P', b'N', b'G'])),
150/// ]));
151/// ```
152#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
153pub enum ContentBlock {
154 /// A text block.
155 Text(String),
156 /// An image block (raw bytes + MIME type). Provider implementations
157 /// encode it for the wire format (e.g. the OpenAI-compatible
158 /// `image_url` content block with a base64 data URL); Memory counts it
159 /// as no tokens and summarizers render it as a placeholder.
160 Image(ImageContent),
161 /// A vendor-shaped content block passed through verbatim (e.g. the
162 /// `input_audio` / `file` parts of OpenAI-compatible endpoints, whose
163 /// shapes vary per vendor and keep evolving).
164 ///
165 /// For any modality without a typed variant, build the wire block
166 /// yourself and wrap it here; the provider inserts it into the content
167 /// array as-is. Whatever your endpoint accepts, this block can carry it.
168 Wire(serde_json::Value),
169}
170
171/// Raw image data carried in a [`ContentBlock::Image`].
172///
173/// Stores the raw bytes so callers never need to base64-encode; serialization
174/// (session persistence / cross-process transport) keeps the bytes as-is.
175#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
176pub struct ImageContent {
177 /// MIME type of the image, e.g. `image/png` / `image/jpeg`; used to build
178 /// the wire data URL (`data:<mime>;base64,...`).
179 pub mime_type: String,
180 /// Raw image bytes (not yet base64-encoded).
181 pub data: Vec<u8>,
182}
183
184impl ImageContent {
185 /// Constructs from raw bytes and a MIME type.
186 ///
187 /// # Example
188 ///
189 /// ```
190 /// # extern crate molo_core as molo;
191 /// use molo::ImageContent;
192 ///
193 /// let image = ImageContent::new("image/png", std::fs::read("logo.png").unwrap_or_default());
194 /// assert_eq!(image.mime_type, "image/png");
195 /// ```
196 pub fn new(mime_type: impl Into<String>, data: Vec<u8>) -> Self {
197 Self {
198 mime_type: mime_type.into(),
199 data,
200 }
201 }
202}
203
204impl Message {
205 /// A system instruction message.
206 pub fn system(content: impl Into<String>) -> Self {
207 Self::System(content.into())
208 }
209
210 /// A plain-text user message (a single text block, equivalent to
211 /// `user_blocks(vec![ContentBlock::Text(content)])`).
212 pub fn user(content: impl Into<String>) -> Self {
213 Self::User(vec![ContentBlock::Text(content.into())])
214 }
215
216 /// User input made of content blocks (text / images / vendor-shaped
217 /// pass-through blocks).
218 pub fn user_blocks(blocks: Vec<ContentBlock>) -> Self {
219 Self::User(blocks)
220 }
221
222 /// An assistant reply message (no reasoning, no tool requests).
223 pub fn assistant(content: impl Into<String>) -> Self {
224 Self::Assistant {
225 content: content.into(),
226 reasoning: None,
227 tool_calls: Vec::new(),
228 }
229 }
230
231 /// An assistant reply with reasoning (for thinking models; pass
232 /// `reasoning` back verbatim when sending history, otherwise the API
233 /// rejects the request).
234 pub fn assistant_with_reasoning(
235 content: impl Into<String>,
236 reasoning: impl Into<String>,
237 ) -> Self {
238 Self::Assistant {
239 content: content.into(),
240 reasoning: Some(reasoning.into()),
241 tool_calls: Vec::new(),
242 }
243 }
244
245 /// A tool execution result passed back to the model; `id` corresponds to
246 /// the [`ToolCall::id`] in the [`Message::Assistant`] message that
247 /// carried the request.
248 ///
249 /// # Example
250 ///
251 /// ```
252 /// # extern crate molo_core as molo;
253 /// use molo::Message;
254 ///
255 /// let result = Message::tool_result("call_1", "Sunny, 23°C");
256 /// assert_eq!(result, Message::ToolResult {
257 /// id: "call_1".into(),
258 /// content: "Sunny, 23°C".into(),
259 /// });
260 /// ```
261 pub fn tool_result(id: impl Into<String>, content: impl Into<String>) -> Self {
262 Self::ToolResult {
263 id: id.into(),
264 content: content.into(),
265 }
266 }
267}