Skip to main content

tirea_contract/thread/
message.rs

1//! Core types for Agent messages and tool calls.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Message role.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum Role {
10    System,
11    User,
12    Assistant,
13    Tool,
14}
15
16/// Message visibility — controls whether a message is exposed to external API consumers.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Visibility {
20    /// Visible to both the user and the LLM.
21    #[default]
22    All,
23    /// Only visible to the LLM, hidden from external API consumers.
24    Internal,
25}
26
27impl Visibility {
28    /// Returns `true` if this is the default visibility (`All`).
29    pub fn is_default(&self) -> bool {
30        *self == Visibility::All
31    }
32}
33
34/// Optional metadata associating a message with a run and step.
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub struct MessageMetadata {
37    /// The run that produced this message.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub run_id: Option<String>,
40    /// Step (round) index within the run (0-based).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub step_index: Option<u32>,
43}
44
45/// Generate a time-ordered UUID v7 message identifier.
46pub fn gen_message_id() -> String {
47    uuid::Uuid::now_v7().to_string()
48}
49
50/// A message in the conversation.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Message {
53    /// Stable message identifier (UUID v7, auto-generated).
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub id: Option<String>,
56    pub role: Role,
57    pub content: String,
58    /// Tool calls made by the assistant.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub tool_calls: Option<Vec<ToolCall>>,
61    /// Tool call ID this message responds to (for tool role).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub tool_call_id: Option<String>,
64    /// Message visibility. Defaults to `All` (visible everywhere).
65    /// Internal messages (e.g. system reminders) are only sent to the LLM.
66    #[serde(default, skip_serializing_if = "Visibility::is_default")]
67    pub visibility: Visibility,
68    /// Optional run/step association metadata.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub metadata: Option<MessageMetadata>,
71}
72
73impl Message {
74    /// Create a system message.
75    pub fn system(content: impl Into<String>) -> Self {
76        Self {
77            id: Some(gen_message_id()),
78            role: Role::System,
79            content: content.into(),
80            tool_calls: None,
81            tool_call_id: None,
82            visibility: Visibility::All,
83            metadata: None,
84        }
85    }
86
87    /// Create an internal system message (visible only to LLM, hidden from API consumers).
88    ///
89    /// Use this for plugin-injected reminders, system hints, and other messages
90    /// that should be part of the LLM context but not exposed to end users.
91    pub fn internal_system(content: impl Into<String>) -> Self {
92        Self {
93            id: Some(gen_message_id()),
94            role: Role::System,
95            content: content.into(),
96            tool_calls: None,
97            tool_call_id: None,
98            visibility: Visibility::Internal,
99            metadata: None,
100        }
101    }
102
103    /// Create a user message.
104    pub fn user(content: impl Into<String>) -> Self {
105        Self {
106            id: Some(gen_message_id()),
107            role: Role::User,
108            content: content.into(),
109            tool_calls: None,
110            tool_call_id: None,
111            visibility: Visibility::All,
112            metadata: None,
113        }
114    }
115
116    /// Create an assistant message.
117    pub fn assistant(content: impl Into<String>) -> Self {
118        Self {
119            id: Some(gen_message_id()),
120            role: Role::Assistant,
121            content: content.into(),
122            tool_calls: None,
123            tool_call_id: None,
124            visibility: Visibility::All,
125            metadata: None,
126        }
127    }
128
129    /// Create an assistant message with tool calls.
130    pub fn assistant_with_tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Self {
131        Self {
132            id: Some(gen_message_id()),
133            role: Role::Assistant,
134            content: content.into(),
135            tool_calls: if calls.is_empty() { None } else { Some(calls) },
136            tool_call_id: None,
137            visibility: Visibility::All,
138            metadata: None,
139        }
140    }
141
142    /// Create a tool response message.
143    pub fn tool(call_id: impl Into<String>, content: impl Into<String>) -> Self {
144        Self {
145            id: Some(gen_message_id()),
146            role: Role::Tool,
147            content: content.into(),
148            tool_calls: None,
149            tool_call_id: Some(call_id.into()),
150            visibility: Visibility::All,
151            metadata: None,
152        }
153    }
154
155    /// Override the auto-generated message ID.
156    #[must_use]
157    pub fn with_id(mut self, id: String) -> Self {
158        self.id = Some(id);
159        self
160    }
161
162    /// Attach run/step metadata to this message.
163    #[must_use]
164    pub fn with_metadata(mut self, metadata: MessageMetadata) -> Self {
165        self.metadata = Some(metadata);
166        self
167    }
168}
169
170/// A tool call requested by the LLM.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ToolCall {
173    /// Unique identifier for this tool call.
174    pub id: String,
175    /// Name of the tool to call.
176    pub name: String,
177    /// Arguments for the tool as JSON.
178    pub arguments: Value,
179}
180
181impl ToolCall {
182    /// Create a new tool call.
183    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
184        Self {
185            id: id.into(),
186            name: name.into(),
187            arguments,
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use serde_json::json;
196
197    #[test]
198    fn test_user_message() {
199        let msg = Message::user("Hello");
200        assert_eq!(msg.role, Role::User);
201        assert_eq!(msg.content, "Hello");
202        assert!(msg.id.is_some());
203        assert!(msg.tool_calls.is_none());
204        assert!(msg.tool_call_id.is_none());
205        assert!(msg.metadata.is_none());
206    }
207
208    #[test]
209    fn test_all_constructors_generate_uuid_v7_id() {
210        let msgs = vec![
211            Message::system("sys"),
212            Message::internal_system("internal"),
213            Message::user("usr"),
214            Message::assistant("asst"),
215            Message::assistant_with_tool_calls("tc", vec![]),
216            Message::tool("c1", "result"),
217        ];
218        for msg in &msgs {
219            let id = msg.id.as_ref().expect("message should have an id");
220            // UUID v7 format: 8-4-4-4-12 hex chars
221            assert_eq!(id.len(), 36, "id should be UUID format: {}", id);
222            assert_eq!(&id[14..15], "7", "UUID version should be 7: {}", id);
223        }
224        // All IDs should be unique
225        let ids: std::collections::HashSet<&str> =
226            msgs.iter().map(|m| m.id.as_deref().unwrap()).collect();
227        assert_eq!(ids.len(), msgs.len());
228    }
229
230    #[test]
231    fn test_assistant_with_tool_calls() {
232        let calls = vec![ToolCall::new("call_1", "search", json!({"query": "rust"}))];
233        let msg = Message::assistant_with_tool_calls("Let me search", calls);
234
235        assert_eq!(msg.role, Role::Assistant);
236        assert_eq!(msg.content, "Let me search");
237        assert!(msg.tool_calls.is_some());
238        assert_eq!(msg.tool_calls.as_ref().unwrap().len(), 1);
239    }
240
241    #[test]
242    fn test_tool_message() {
243        let msg = Message::tool("call_1", "Result: 42");
244
245        assert_eq!(msg.role, Role::Tool);
246        assert_eq!(msg.content, "Result: 42");
247        assert_eq!(msg.tool_call_id.as_deref(), Some("call_1"));
248    }
249
250    #[test]
251    fn test_message_serialization() {
252        let msg = Message::user("test");
253        let json = serde_json::to_string(&msg).unwrap();
254        assert!(json.contains("\"role\":\"user\""));
255        // tool_calls, tool_call_id, metadata should be omitted when None/default
256        assert!(!json.contains("tool_calls"));
257        assert!(!json.contains("tool_call_id"));
258        assert!(!json.contains("metadata"));
259    }
260
261    #[test]
262    fn test_message_with_metadata_serialization() {
263        let msg = Message::user("test").with_metadata(MessageMetadata {
264            run_id: Some("run-1".to_string()),
265            step_index: Some(3),
266        });
267        let json = serde_json::to_string(&msg).unwrap();
268        assert!(json.contains("\"run_id\":\"run-1\""));
269        assert!(json.contains("\"step_index\":3"));
270
271        // Round-trip
272        let parsed: Message = serde_json::from_str(&json).unwrap();
273        let meta = parsed.metadata.unwrap();
274        assert_eq!(meta.run_id.as_deref(), Some("run-1"));
275        assert_eq!(meta.step_index, Some(3));
276    }
277
278    #[test]
279    fn test_message_without_metadata_deserializes() {
280        // Old JSON without metadata field should deserialize fine
281        let json = r#"{"id":"abc","role":"user","content":"hello"}"#;
282        let msg: Message = serde_json::from_str(json).unwrap();
283        assert!(msg.metadata.is_none());
284        assert_eq!(msg.visibility, Visibility::All);
285    }
286
287    #[test]
288    fn test_tool_call_serialization() {
289        let call = ToolCall::new("id_1", "calculator", json!({"expr": "2+2"}));
290        let json = serde_json::to_string(&call).unwrap();
291        let parsed: ToolCall = serde_json::from_str(&json).unwrap();
292
293        assert_eq!(parsed.id, "id_1");
294        assert_eq!(parsed.name, "calculator");
295        assert_eq!(parsed.arguments["expr"], "2+2");
296    }
297
298    #[test]
299    fn test_with_id_overrides_auto_generated() {
300        let msg = Message::user("hi").with_id("custom-id".to_string());
301        assert_eq!(msg.id.as_deref(), Some("custom-id"));
302    }
303
304    #[test]
305    fn test_gen_message_id_is_public_and_uuid_v7() {
306        let id = gen_message_id();
307        assert_eq!(id.len(), 36);
308        assert_eq!(&id[14..15], "7");
309    }
310}