Skip to main content

agentyk_core/
message.rs

1//! Chat messages exchanged with LLM drivers and reconstructed from the event
2//! log. Mirrors the everruns message vocabulary (`Message`, `Role`,
3//! `ToolCall`, `ContentPart`).
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9/// Who a [`Message`] came from.
10///
11/// Deliberately exhaustive: a driver that failed to translate a new role
12/// would silently drop part of the conversation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Role {
16    /// Instructions framing the conversation. Most drivers send these
17    /// out-of-band rather than as a message — see
18    /// [`crate::driver::ChatRequest::system_prompt`].
19    System,
20    /// Input from whoever is talking to the agent.
21    User,
22    /// The model's own output: text, tool calls, or both.
23    Assistant,
24    /// A tool result addressed back to the model.
25    Tool,
26}
27
28/// Identity of a user message originating outside the embedding application.
29///
30/// Channel adapters attach this value so several people can speak in one
31/// session without coupling the core protocol to Slack, Discord, Teams, or
32/// another transport. The model-facing request prefixes the user's text with
33/// [`Self::display_label`]; the durable message itself remains unchanged.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct ExternalActor {
36    /// Opaque identifier assigned by the source, such as a Slack user id.
37    pub actor_id: String,
38    /// Human-readable name, when the source resolved one.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub actor_name: Option<String>,
41    /// Source namespace, such as `slack` or `discord`.
42    pub source: String,
43    /// Source-specific attributes useful to the embedding host.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub metadata: Option<HashMap<String, String>>,
46}
47
48impl ExternalActor {
49    /// Create an actor from its source namespace and opaque source id.
50    pub fn new(source: impl Into<String>, actor_id: impl Into<String>) -> Self {
51        Self {
52            actor_id: actor_id.into(),
53            actor_name: None,
54            source: source.into(),
55            metadata: None,
56        }
57    }
58
59    /// Attach the name shown to the model and presentation layers.
60    pub fn name(mut self, name: impl Into<String>) -> Self {
61        self.actor_name = Some(name.into());
62        self
63    }
64
65    /// Human-readable name when present, otherwise the source id.
66    pub fn display_label(&self) -> &str {
67        self.actor_name.as_deref().unwrap_or(&self.actor_id)
68    }
69}
70
71/// A tool invocation requested by the model.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct ToolCall {
74    /// Driver-assigned call id, echoed back in the tool result.
75    pub id: String,
76    /// Which tool to run — matched against [`crate::tool::ToolDefinition`]
77    /// names.
78    pub name: String,
79    /// Arguments as a JSON object. The model produces these, so treat them as
80    /// untrusted: a tool validates its own arguments.
81    #[serde(default)]
82    pub arguments: serde_json::Value,
83}
84
85/// Text content.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct TextContentPart {
88    /// The text itself.
89    pub text: String,
90}
91
92/// Image content, as a URL or inline base64 data (mirrors everruns'
93/// `ImageContentPart`).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ImageContentPart {
96    /// Where the image lives, for providers that fetch it themselves.
97    /// Mutually exclusive with [`ImageContentPart::base64`].
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub url: Option<String>,
100    /// The image inline, base64-encoded.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub base64: Option<String>,
103    /// MIME type of the inline data, e.g. `"image/png"`. Required alongside
104    /// `base64`; drivers assume PNG without it.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub media_type: Option<String>,
107}
108
109impl ImageContentPart {
110    /// An image the provider fetches for itself.
111    pub fn from_url(url: impl Into<String>) -> Self {
112        Self {
113            url: Some(url.into()),
114            base64: None,
115            media_type: None,
116        }
117    }
118
119    /// An image sent inline, which needs no reachable URL.
120    pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
121        Self {
122            url: None,
123            base64: Some(base64.into()),
124            media_type: Some(media_type.into()),
125        }
126    }
127}
128
129/// So a turn can be opened with either a string or a whole message — see
130/// `Session::run`. Text is by far the common case and should stay a one-liner;
131/// an image the model is asked to look at should not need a different method.
132impl From<String> for Message {
133    fn from(text: String) -> Self {
134        Message::user(text)
135    }
136}
137
138impl From<&str> for Message {
139    fn from(text: &str) -> Self {
140        Message::user(text)
141    }
142}
143
144/// A part of message content: text or an image. Tool calls and tool results
145/// stay as dedicated [`Message`] fields rather than content-part variants —
146/// a deliberate simplification over everruns' unified content-part model.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(tag = "type", rename_all = "snake_case")]
149pub enum ContentPart {
150    /// Text.
151    Text(TextContentPart),
152    /// An image, by URL or inline.
153    Image(ImageContentPart),
154}
155
156impl ContentPart {
157    /// A text part.
158    pub fn text(text: impl Into<String>) -> Self {
159        ContentPart::Text(TextContentPart { text: text.into() })
160    }
161
162    /// An image part the provider fetches by URL.
163    pub fn image_url(url: impl Into<String>) -> Self {
164        ContentPart::Image(ImageContentPart::from_url(url))
165    }
166
167    /// An image part sent inline.
168    pub fn image_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
169        ContentPart::Image(ImageContentPart::from_base64(base64, media_type))
170    }
171
172    /// The text of a text part, or `None` for anything else.
173    pub fn as_text(&self) -> Option<&str> {
174        match self {
175            ContentPart::Text(part) => Some(&part.text),
176            ContentPart::Image(_) => None,
177        }
178    }
179}
180
181/// One message in a conversation.
182///
183/// This is both what drivers send to a provider and what replaying the event
184/// log reconstructs, so anything that must survive a resume lives here rather
185/// than beside it.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct Message {
188    /// Who produced it.
189    pub role: Role,
190    /// Content parts, in order. Text-only messages carry a single `Text`
191    /// part (or none, for an empty message) — see [`Message::text`].
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub content: Vec<ContentPart>,
194    /// Tool calls requested by an assistant message.
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub tool_calls: Vec<ToolCall>,
197    /// For `Role::Tool` messages: the call this result answers.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub tool_call_id: Option<String>,
200    /// Provider reasoning ("extended thinking") text, when the model emitted
201    /// a reasoning block. First-class because it is **not** everruns-specific
202    /// (OpenAI reasoning, Anthropic thinking) and it must round-trip back to
203    /// the provider on subsequent turns for the exchange to stay valid —
204    /// which an out-of-band store can't guarantee (the driver only ever sees
205    /// `Message`). Empty for the common no-reasoning case.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub thinking: Option<String>,
208    /// Opaque signature the provider requires to accept a replayed thinking
209    /// block (e.g. Anthropic's `signature`). Paired with [`Message::thinking`].
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub thinking_signature: Option<String>,
212    /// Generic, serializable extension hatch — the message analogue of
213    /// [`crate::event::EventData::Custom`]. Everruns-flavored richness a
214    /// adopting host owns (execution `phase`, narration hints, provider
215    /// extras) rides here instead of growing typed core fields. `Null` for
216    /// the common case.
217    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
218    pub metadata: serde_json::Value,
219    /// External identity of the user who produced this message.
220    ///
221    /// Kept separate from generic metadata because correct multi-actor model
222    /// context depends on it: drivers must be able to distinguish speakers.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub external_actor: Option<ExternalActor>,
225}
226
227impl Message {
228    fn text_content(text: impl Into<String>) -> Vec<ContentPart> {
229        let text = text.into();
230        if text.is_empty() {
231            Vec::new()
232        } else {
233            vec![ContentPart::text(text)]
234        }
235    }
236
237    fn new(role: Role, content: Vec<ContentPart>) -> Self {
238        Self {
239            role,
240            content,
241            tool_calls: Vec::new(),
242            tool_call_id: None,
243            thinking: None,
244            thinking_signature: None,
245            metadata: serde_json::Value::Null,
246            external_actor: None,
247        }
248    }
249
250    /// A plain-text user message.
251    pub fn user(text: impl Into<String>) -> Self {
252        Self::new(Role::User, Self::text_content(text))
253    }
254
255    /// A user message with arbitrary content parts (text, images).
256    pub fn user_multimodal(content: Vec<ContentPart>) -> Self {
257        Self::new(Role::User, content)
258    }
259
260    /// A plain-text assistant message.
261    pub fn assistant(text: impl Into<String>) -> Self {
262        Self::new(Role::Assistant, Self::text_content(text))
263    }
264
265    /// An assistant message requesting tools, with optional accompanying
266    /// text. An empty `text` is normal: models often answer with calls alone.
267    pub fn assistant_with_calls(text: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
268        Self {
269            tool_calls,
270            ..Self::new(Role::Assistant, Self::text_content(text))
271        }
272    }
273
274    /// A tool's result, addressed back to the call it answers. The turn loop
275    /// builds these from `tool.completed` events, so a resumed session
276    /// reconstructs them identically.
277    pub fn tool_result(call_id: impl Into<String>, text: impl Into<String>) -> Self {
278        Self {
279            tool_call_id: Some(call_id.into()),
280            ..Self::new(Role::Tool, Self::text_content(text))
281        }
282    }
283
284    /// A tool result carrying extra content parts (images) after its text —
285    /// see [`crate::tool::ToolOutput::parts`]. Equivalent to
286    /// [`Message::tool_result`] when `parts` is empty.
287    pub fn tool_result_with_parts(
288        call_id: impl Into<String>,
289        text: impl Into<String>,
290        parts: impl IntoIterator<Item = ContentPart>,
291    ) -> Self {
292        let mut content = Self::text_content(text);
293        content.extend(parts);
294        Self {
295            tool_call_id: Some(call_id.into()),
296            ..Self::new(Role::Tool, content)
297        }
298    }
299
300    /// Attach provider reasoning to this message — see [`Message::thinking`].
301    pub fn with_thinking(mut self, thinking: impl Into<String>, signature: Option<String>) -> Self {
302        self.thinking = Some(thinking.into());
303        self.thinking_signature = signature;
304        self
305    }
306
307    /// Attach extension metadata — see [`Message::metadata`].
308    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
309        self.metadata = metadata;
310        self
311    }
312
313    /// Attribute a user message to an external channel actor.
314    pub fn with_external_actor(mut self, actor: ExternalActor) -> Self {
315        self.external_actor = Some(actor);
316        self
317    }
318
319    /// The concatenation of every text part, in order. Empty if the message
320    /// has no text content (e.g. an image-only message, or a tool-call-only
321    /// assistant message).
322    pub fn text(&self) -> String {
323        self.content
324            .iter()
325            .filter_map(ContentPart::as_text)
326            .collect::<Vec<_>>()
327            .join("")
328    }
329
330    /// True if every content part is text (no images) — the common case,
331    /// and the signal drivers use to decide whether they can send a plain
332    /// string on the wire instead of a content-part array.
333    pub fn is_text_only(&self) -> bool {
334        self.content
335            .iter()
336            .all(|part| matches!(part, ContentPart::Text(_)))
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn text_constructors_round_trip_through_text() {
346        assert_eq!(Message::user("hi").text(), "hi");
347        assert_eq!(Message::assistant("").text(), "");
348        assert!(Message::assistant("").content.is_empty());
349    }
350
351    #[test]
352    fn multimodal_message_mixes_text_and_image() {
353        let message = Message::user_multimodal(vec![
354            ContentPart::text("what is this?"),
355            ContentPart::image_url("https://example.com/cat.png"),
356        ]);
357        assert_eq!(message.text(), "what is this?");
358        assert!(!message.is_text_only());
359    }
360
361    #[test]
362    fn serde_roundtrip_preserves_content_parts() {
363        let message = Message::user_multimodal(vec![
364            ContentPart::text("a"),
365            ContentPart::image_base64("Zm9v", "image/png"),
366        ]);
367        let json = serde_json::to_string(&message).unwrap();
368        let back: Message = serde_json::from_str(&json).unwrap();
369        assert_eq!(message, back);
370    }
371
372    #[test]
373    fn thinking_and_metadata_round_trip() {
374        let message = Message::assistant("done")
375            .with_thinking("let me consider…", Some("sig-abc".into()))
376            .with_metadata(serde_json::json!({"phase": "final"}));
377        let json = serde_json::to_string(&message).unwrap();
378        let back: Message = serde_json::from_str(&json).unwrap();
379        assert_eq!(message, back);
380        assert_eq!(back.thinking.as_deref(), Some("let me consider…"));
381        assert_eq!(back.thinking_signature.as_deref(), Some("sig-abc"));
382        assert_eq!(back.metadata["phase"], "final");
383    }
384
385    #[test]
386    fn external_actor_round_trips_and_uses_name_as_label() {
387        let message = Message::user("hello")
388            .with_external_actor(ExternalActor::new("slack", "U123").name("Alice"));
389        let json = serde_json::to_string(&message).unwrap();
390        let back: Message = serde_json::from_str(&json).unwrap();
391
392        assert_eq!(back, message);
393        assert_eq!(
394            back.external_actor.as_ref().unwrap().display_label(),
395            "Alice"
396        );
397    }
398
399    #[test]
400    fn plain_messages_omit_the_new_fields_from_json() {
401        // Back-compat: a message with no reasoning/metadata serializes exactly
402        // as before, and pre-0.1.1 JSON (without these keys) still loads.
403        let json = serde_json::to_string(&Message::user("hi")).unwrap();
404        assert!(!json.contains("thinking"));
405        assert!(!json.contains("metadata"));
406        assert!(!json.contains("external_actor"));
407        let back: Message =
408            serde_json::from_str(r#"{"role":"user","content":[{"type":"text","text":"hi"}]}"#)
409                .unwrap();
410        assert_eq!(back, Message::user("hi"));
411    }
412}