Skip to main content

juncture_core/state/
messages.rs

1use serde::{Deserialize, Serialize};
2
3/// Message type for LLM conversations.
4///
5/// Used in agent workflows with message-based state.
6#[derive(Clone, Debug, Serialize, Deserialize)]
7pub struct Message {
8    /// Unique message identifier
9    pub id: String,
10    /// Message role (system, human, ai, tool)
11    pub role: Role,
12    /// Message content (text or multimodal)
13    pub content: Content,
14    /// Tool calls made by the AI (for AI messages)
15    pub tool_calls: Vec<ToolCall>,
16    /// Tool call ID this message responds to (for tool messages)
17    pub tool_call_id: Option<String>,
18    /// Optional name for the message sender
19    pub name: Option<String>,
20    /// Token usage information from LLM API responses
21    pub usage: Option<TokenUsage>,
22}
23
24/// Message role
25#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
26pub enum Role {
27    /// System message
28    System,
29    /// Human/user message
30    Human,
31    /// AI/assistant message
32    Ai,
33    /// Tool result message
34    Tool,
35}
36
37/// Message content
38#[derive(Clone, Debug, Serialize, Deserialize)]
39pub enum Content {
40    /// Simple text content
41    Text(String),
42    /// Multimodal content with multiple parts
43    MultiPart(Vec<ContentPart>),
44}
45
46/// Content part for multimodal messages
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub enum ContentPart {
49    /// Text content
50    Text { text: String },
51    /// Image data
52    Image(ImageData),
53    /// Extended thinking content (Anthropic API)
54    ///
55    /// Contains the model's internal reasoning process without affecting tool calls.
56    Thinking {
57        text: String,
58        signature: Option<String>,
59    },
60}
61
62/// Image data for multimodal content
63#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct ImageData {
65    /// Media type (e.g., "image/png", "image/jpeg")
66    pub media_type: String,
67    /// Image source data
68    pub source: ImageSource,
69}
70
71/// Image source for multimodal content
72#[derive(Clone, Debug, Serialize, Deserialize)]
73pub enum ImageSource {
74    /// Base64-encoded image data
75    Base64(String),
76    /// Image URL
77    Url(String),
78}
79
80/// Tool call within a message
81#[derive(Clone, Debug, Serialize, Deserialize)]
82pub struct ToolCall {
83    /// Unique tool call identifier
84    pub id: String,
85    /// Tool name
86    pub name: String,
87    /// Tool arguments as JSON value
88    pub arguments: serde_json::Value,
89}
90
91/// Token usage information from LLM API responses
92#[derive(Clone, Debug, Default, Serialize, Deserialize)]
93pub struct TokenUsage {
94    /// Number of input tokens
95    pub input_tokens: u64,
96    /// Number of output tokens
97    pub output_tokens: u64,
98    /// Total tokens used
99    pub total_tokens: u64,
100}
101
102/// Special sentinel: remove all messages
103///
104/// Used to clear the entire messages list.
105pub const REMOVE_ALL_MESSAGES: &str = "__remove_all__";
106
107/// Built-in state for simple chat agents
108///
109/// Provides a zero-config entry point for simple chat agents with a
110/// single `messages` field using the messages reducer semantics.
111///
112/// # Examples
113///
114/// ```
115/// use juncture_core::state::messages::{MessagesState, Message};
116///
117/// let mut state = MessagesState::default();
118/// state.messages.push(Message::human("Hello"));
119/// state.messages.push(Message::ai("Hi there!"));
120/// ```
121#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
122pub struct MessagesState {
123    /// Message history using append+merge+delete semantics
124    pub messages: Vec<Message>,
125}
126
127/// Update type for `MessagesState`
128///
129/// All fields are optional to support partial updates.
130#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
131pub struct MessagesStateUpdate {
132    /// Optional messages update
133    pub messages: Option<Vec<Message>>,
134}
135
136impl crate::State for MessagesState {
137    type Update = MessagesStateUpdate;
138    type FieldVersions = crate::state::FieldVersions;
139
140    fn apply(&mut self, update: Self::Update) -> crate::FieldsChanged {
141        let mut changed = crate::FieldsChanged(0);
142
143        if let Some(messages) = update.messages {
144            messages_reducer(&mut self.messages, messages);
145            changed.0 |= 1 << 0;
146        }
147
148        changed
149    }
150
151    fn reset_ephemeral(&mut self) {
152        // No ephemeral fields in MessagesState
153    }
154}
155
156impl MessagesState {
157    /// Apply an update with structured error propagation for reducer violations
158    ///
159    /// The messages reducer is an append+merge+delete reducer that never
160    /// conflicts, so this always succeeds. Provided for trait consistency.
161    ///
162    /// # Errors
163    ///
164    /// This method never returns an error, as the messages reducer has no
165    /// write-conflict semantics. The `Result` return type is for API
166    /// consistency with `State::try_apply()`.
167    pub fn try_apply_messages(
168        &mut self,
169        update: MessagesStateUpdate,
170    ) -> Result<crate::FieldsChanged, crate::error::InvalidUpdateError> {
171        Ok(crate::State::apply(self, update))
172    }
173}
174
175/// Messages reducer with append+merge+delete semantics
176///
177/// Handles message updates, deletions, and appends.
178/// - If message ID matches existing message: update it
179/// - If message ID starts with "__remove__:": delete that message
180/// - If message is `REMOVE_ALL_MESSAGES`: clear all messages
181/// - Otherwise: append the message
182pub fn messages_reducer(current: &mut Vec<Message>, incoming: Vec<Message>) {
183    for msg in incoming {
184        if msg.id == REMOVE_ALL_MESSAGES {
185            current.clear();
186        } else if msg.id.starts_with("__remove__:") {
187            let target_id = &msg.id["__remove__:".len()..];
188            current.retain(|m| m.id != target_id);
189        } else if let Some(existing) = current.iter_mut().find(|m| m.id == msg.id) {
190            *existing = msg;
191        } else {
192            current.push(msg);
193        }
194    }
195}
196
197impl Message {
198    /// Create a human message
199    pub fn human(content: impl Into<String>) -> Self {
200        Self {
201            id: uuid::Uuid::new_v4().to_string(),
202            role: Role::Human,
203            content: Content::Text(content.into()),
204            tool_calls: vec![],
205            tool_call_id: None,
206            name: None,
207            usage: None,
208        }
209    }
210
211    /// Create an AI message
212    pub fn ai(content: impl Into<String>) -> Self {
213        Self {
214            id: uuid::Uuid::new_v4().to_string(),
215            role: Role::Ai,
216            content: Content::Text(content.into()),
217            tool_calls: vec![],
218            tool_call_id: None,
219            name: None,
220            usage: None,
221        }
222    }
223
224    /// Create an AI message with tool calls
225    pub fn ai_with_tool_calls(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
226        Self {
227            id: uuid::Uuid::new_v4().to_string(),
228            role: Role::Ai,
229            content: Content::Text(content.into()),
230            tool_calls,
231            tool_call_id: None,
232            name: None,
233            usage: None,
234        }
235    }
236
237    /// Create a system message
238    pub fn system(content: impl Into<String>) -> Self {
239        Self {
240            id: uuid::Uuid::new_v4().to_string(),
241            role: Role::System,
242            content: Content::Text(content.into()),
243            tool_calls: vec![],
244            tool_call_id: None,
245            name: None,
246            usage: None,
247        }
248    }
249
250    /// Create a tool result message
251    pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
252        Self {
253            id: uuid::Uuid::new_v4().to_string(),
254            role: Role::Tool,
255            content: Content::Text(content.into()),
256            tool_calls: vec![],
257            tool_call_id: Some(tool_call_id.into()),
258            name: None,
259            usage: None,
260        }
261    }
262
263    /// Check if message has tool calls
264    #[must_use]
265    pub const fn has_tool_calls(&self) -> bool {
266        !self.tool_calls.is_empty()
267    }
268
269    /// Extract text content from the message
270    ///
271    /// For `Content::Text`, returns the text directly.
272    /// For `Content::MultiPart`, returns the text of the first `ContentPart::Text` found,
273    /// or an empty string if no text part exists.
274    #[must_use]
275    pub fn content_text(&self) -> &str {
276        match &self.content {
277            Content::Text(s) => s,
278            Content::MultiPart(parts) => parts
279                .iter()
280                .find_map(|p| match p {
281                    ContentPart::Text { text } => Some(text.as_str()),
282                    _ => None,
283                })
284                .unwrap_or(""),
285        }
286    }
287
288    /// Create a remove message sentinel
289    #[must_use]
290    pub fn remove(id: impl Into<String>) -> Self {
291        let id = id.into();
292        Self {
293            id: format!("__remove__:{id}"),
294            role: Role::System,
295            content: Content::Text(String::new()),
296            tool_calls: vec![],
297            tool_call_id: None,
298            name: None,
299            usage: None,
300        }
301    }
302
303    /// Create a remove-all message sentinel
304    ///
305    /// This message clears the entire messages list when processed
306    /// by the messages reducer. The sentinel has a special ID
307    /// (`REMOVE_ALL_MESSAGES`) that triggers the clear operation.
308    #[must_use]
309    pub fn remove_all() -> Self {
310        Self {
311            id: REMOVE_ALL_MESSAGES.to_string(),
312            role: Role::System,
313            content: Content::Text(String::new()),
314            tool_calls: vec![],
315            tool_call_id: None,
316            name: None,
317            usage: None,
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::state::trait_::State;
326
327    #[test]
328    fn test_messages_state_default() {
329        let state = MessagesState::default();
330        assert!(state.messages.is_empty());
331    }
332
333    #[test]
334    fn test_messages_state_apply() {
335        let mut state = MessagesState::default();
336
337        let update = MessagesStateUpdate {
338            messages: Some(vec![Message::human("Hello")]),
339        };
340
341        let changed = state.apply(update);
342        assert_eq!(state.messages.len(), 1);
343        assert!(!changed.is_empty());
344        assert!(changed.has_field(0));
345    }
346
347    #[test]
348    fn test_messages_state_apply_merge() {
349        let mut state = MessagesState {
350            messages: vec![Message::human("Hello")],
351        };
352
353        let update = MessagesStateUpdate {
354            messages: Some(vec![Message::ai("Hi there!")]),
355        };
356
357        state.apply(update);
358        assert_eq!(state.messages.len(), 2);
359    }
360
361    #[test]
362    fn test_messages_state_apply_none() {
363        let mut state = MessagesState {
364            messages: vec![Message::human("Hello")],
365        };
366
367        let update = MessagesStateUpdate { messages: None };
368
369        let changed = state.apply(update);
370        assert_eq!(state.messages.len(), 1);
371        assert!(changed.is_empty());
372    }
373
374    #[test]
375    fn test_messages_state_reset_ephemeral() {
376        let mut state = MessagesState {
377            messages: vec![Message::human("Hello")],
378        };
379
380        state.reset_ephemeral();
381        // No-op for MessagesState since it has no ephemeral fields
382        assert_eq!(state.messages.len(), 1);
383    }
384
385    #[test]
386    fn test_messages_state_serialization() {
387        let state = MessagesState {
388            messages: vec![Message::human("Hello")],
389        };
390
391        let json = serde_json::to_string(&state).unwrap();
392        let deserialized: MessagesState = serde_json::from_str(&json).unwrap();
393
394        assert_eq!(deserialized.messages.len(), 1);
395        assert_eq!(deserialized.messages[0].role, Role::Human);
396    }
397
398    #[test]
399    fn test_messages_state_update_serialization() {
400        let update = MessagesStateUpdate {
401            messages: Some(vec![Message::ai("Hi!")]),
402        };
403
404        let json = serde_json::to_string(&update).unwrap();
405        let deserialized: MessagesStateUpdate = serde_json::from_str(&json).unwrap();
406
407        assert!(deserialized.messages.is_some());
408        assert_eq!(deserialized.messages.unwrap().len(), 1);
409    }
410}
411
412// Rust guideline compliant 2026-05-20