Skip to main content

a2a_rs/domain/core/
message.rs

1use bon::Builder;
2use serde::{Deserialize, Deserializer, Serialize};
3use serde_json::{Map, Value};
4
5#[cfg(feature = "tracing")]
6use tracing::instrument;
7
8use crate::domain::error::A2AError;
9
10/// Roles in agent communication (user or agent).
11///
12/// Distinguishes between messages sent by users (human or system)
13/// and messages sent by agents in the conversation flow.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "lowercase")]
16pub enum Role {
17    User,
18    Agent,
19}
20
21/// File content representation supporting both embedded data and URIs.
22///
23/// Files can be represented either as base64-encoded embedded data
24/// or as URIs pointing to external resources. The implementation
25/// validates that exactly one of `bytes` or `uri` is provided.
26///
27/// # Example
28/// ```rust
29/// use a2a_rs::FileContent;
30///
31/// // Embedded file content
32/// let embedded = FileContent {
33///     name: Some("example.txt".to_string()),
34///     mime_type: Some("text/plain".to_string()),
35///     bytes: Some("SGVsbG8gV29ybGQ=".to_string()), // "Hello World" in base64
36///     uri: None,
37/// };
38///
39/// // URI-based file content  
40/// let uri_based = FileContent {
41///     name: Some("document.pdf".to_string()),
42///     mime_type: Some("application/pdf".to_string()),
43///     bytes: None,
44///     uri: Some("https://example.com/document.pdf".to_string()),
45/// };
46/// ```
47#[derive(Debug, Clone, Serialize)]
48pub struct FileContent {
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub name: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none", rename = "mimeType")]
52    pub mime_type: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub bytes: Option<String>, // Base64 encoded
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub uri: Option<String>,
57}
58
59// Custom FileContent deserializer that validates the content
60// during deserialization
61impl<'de> Deserialize<'de> for FileContent {
62    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
63    where
64        D: Deserializer<'de>,
65    {
66        // Use a helper struct to deserialize the raw data
67        #[derive(Deserialize)]
68        struct FileContentHelper {
69            name: Option<String>,
70            #[serde(rename = "mimeType")]
71            mime_type: Option<String>,
72            bytes: Option<String>,
73            uri: Option<String>,
74        }
75
76        let helper = FileContentHelper::deserialize(deserializer)?;
77
78        // Create the FileContent
79        let file_content = FileContent {
80            name: helper.name,
81            mime_type: helper.mime_type,
82            bytes: helper.bytes,
83            uri: helper.uri,
84        };
85
86        // Validate and return
87        match file_content.validate() {
88            Ok(_) => Ok(file_content),
89            Err(err) => {
90                // Convert the A2AError to a serde error
91                Err(serde::de::Error::custom(format!(
92                    "FileContent validation error: {}",
93                    err
94                )))
95            }
96        }
97    }
98}
99
100impl FileContent {
101    /// Validates that the file content is properly specified
102    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(
103        file.name = ?self.name,
104        file.has_bytes = self.bytes.is_some(),
105        file.has_uri = self.uri.is_some()
106    )))]
107    pub fn validate(&self) -> Result<(), A2AError> {
108        match (&self.bytes, &self.uri) {
109            (Some(_), None) | (None, Some(_)) => {
110                #[cfg(feature = "tracing")]
111                tracing::debug!("File content validation successful");
112                Ok(())
113            }
114            (Some(_), Some(_)) => {
115                #[cfg(feature = "tracing")]
116                tracing::error!("File content has both bytes and uri");
117                Err(A2AError::InvalidParams(
118                    "Cannot provide both bytes and uri".to_string(),
119                ))
120            }
121            (None, None) => {
122                #[cfg(feature = "tracing")]
123                tracing::error!("File content has neither bytes nor uri");
124                Err(A2AError::InvalidParams(
125                    "Must provide either bytes or uri".to_string(),
126                ))
127            }
128        }
129    }
130}
131
132/// Parts that can make up a message (text, file, or structured data).\n///\n/// Messages in the A2A protocol consist of one or more parts, each of which\n/// can contain different types of content:\n/// - `Text`: Plain text content with optional metadata\n/// - `File`: File content (embedded or URI-based) with optional metadata  \n/// - `Data`: Structured JSON data with optional metadata\n///\n/// Each part type supports optional metadata for additional context.\n///\n/// # Example\n/// ```rust\n/// use a2a_rs::{Part, FileContent};\n/// use serde_json::{Map, Value};\n/// \n/// // Text part\n/// let text_part = Part::Text {\n///     text: \"Hello, world!\".to_string(),\n///     metadata: None,\n/// };\n/// \n/// // File part with metadata\n/// let mut metadata = Map::new();\n/// metadata.insert(\"source\".to_string(), Value::String(\"user_upload\".to_string()));\n/// \n/// let file_part = Part::File {\n///     file: FileContent {\n///         name: Some(\"example.txt\".to_string()),\n///         mime_type: Some(\"text/plain\".to_string()),\n///         bytes: Some(\"SGVsbG8=\".to_string()),\n///         uri: None,\n///     },\n///     metadata: Some(metadata),\n/// };\n/// ```
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(tag = "kind")]
135pub enum Part {
136    #[serde(rename = "text")]
137    Text {
138        text: String,
139        #[serde(skip_serializing_if = "Option::is_none")]
140        metadata: Option<Map<String, Value>>,
141    },
142    #[serde(rename = "file")]
143    File {
144        file: FileContent,
145        #[serde(skip_serializing_if = "Option::is_none")]
146        metadata: Option<Map<String, Value>>,
147    },
148    #[serde(rename = "data")]
149    Data {
150        data: Map<String, Value>,
151        #[serde(skip_serializing_if = "Option::is_none")]
152        metadata: Option<Map<String, Value>>,
153    },
154}
155
156impl Part {
157    /// Helper method to get the text content if this is a Text part
158    #[cfg(test)]
159    pub fn get_text(&self) -> Option<&str> {
160        match self {
161            Part::Text { text, .. } => Some(text),
162            _ => None,
163        }
164    }
165}
166
167/// A message in the A2A protocol containing parts and metadata.
168///
169/// Messages are the primary unit of communication in the A2A protocol.
170/// Each message has a role (user or agent), one or more content parts,
171/// and various IDs for tracking and organization.
172///
173/// # Example
174/// ```rust
175/// use a2a_rs::{Message, Role, Part};
176///
177/// let message = Message::builder()
178///     .role(Role::User)
179///     .parts(vec![Part::Text {
180///         text: "Hello, agent!".to_string(),
181///         metadata: None,
182///     }])
183///     .message_id("msg-123".to_string())
184///     .build();
185/// ```
186#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
187pub struct Message {
188    pub role: Role,
189    #[builder(default = Vec::new())]
190    pub parts: Vec<Part>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub metadata: Option<Map<String, Value>>,
193    #[serde(skip_serializing_if = "Option::is_none", rename = "referenceTaskIds")]
194    pub reference_task_ids: Option<Vec<String>>,
195    #[serde(rename = "messageId")]
196    pub message_id: String,
197    #[serde(skip_serializing_if = "Option::is_none", rename = "taskId")]
198    pub task_id: Option<String>,
199    #[serde(skip_serializing_if = "Option::is_none", rename = "contextId")]
200    pub context_id: Option<String>,
201    /// URIs of extensions relevant to this message (v0.3.0)
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub extensions: Option<Vec<String>>,
204    #[builder(default = "message".to_string())]
205    pub kind: String, // Always "message"
206}
207
208/// An artifact produced by an agent during task processing.
209///
210/// Artifacts represent outputs, intermediate results, or side effects
211/// produced by agents while processing tasks. They can contain various
212/// types of content and include metadata for organization and discovery.
213///
214/// # Example
215/// ```rust
216/// use a2a_rs::{Artifact, Part};
217///
218/// let artifact = Artifact {
219///     artifact_id: "artifact-123".to_string(),
220///     name: Some("Generated Report".to_string()),
221///     description: Some("Analysis report generated from data".to_string()),
222///     parts: vec![Part::Text {
223///         text: "Report content here...".to_string(),
224///         metadata: None,
225///     }],
226///     metadata: None,
227///     extensions: None,
228/// };
229/// ```
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Artifact {
232    #[serde(rename = "artifactId")]
233    pub artifact_id: String,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub name: Option<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub description: Option<String>,
238    pub parts: Vec<Part>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub metadata: Option<Map<String, Value>>,
241    /// URIs of extensions relevant to this artifact (v0.3.0)
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub extensions: Option<Vec<String>>,
244}
245
246/// Helper methods for creating parts
247impl Part {
248    /// Create a text part
249    #[inline]
250    pub fn text(content: String) -> Self {
251        Part::Text {
252            text: content,
253            metadata: None,
254        }
255    }
256
257    /// Create a text part with metadata
258    #[inline]
259    pub fn text_with_metadata(content: String, metadata: Map<String, Value>) -> Self {
260        Part::Text {
261            text: content,
262            metadata: Some(metadata),
263        }
264    }
265
266    /// Create a data part
267    #[inline]
268    pub fn data(data: Map<String, Value>) -> Self {
269        Part::Data {
270            data,
271            metadata: None,
272        }
273    }
274
275    /// Create a file part from base64 encoded data
276    pub fn file_from_bytes(bytes: String, name: Option<String>, mime_type: Option<String>) -> Self {
277        let file_content = FileContent {
278            name,
279            mime_type,
280            bytes: Some(bytes),
281            uri: None,
282        };
283
284        // Validate that FileContent has either bytes or URI (not both, not neither)
285        file_content
286            .validate()
287            .expect("FileContent must have either bytes or uri, not both or neither");
288
289        Part::File {
290            file: file_content,
291            metadata: None,
292        }
293    }
294
295    /// Create a file part from a URI
296    pub fn file_from_uri(uri: String, name: Option<String>, mime_type: Option<String>) -> Self {
297        let file_content = FileContent {
298            name,
299            mime_type,
300            bytes: None,
301            uri: Some(uri),
302        };
303
304        // Validates implicitly as it only has URI and no bytes
305        debug_assert!(
306            file_content.validate().is_ok(),
307            "FileContent validation failed"
308        );
309
310        Part::File {
311            file: file_content,
312            metadata: None,
313        }
314    }
315
316    /// Create a builder-style text part that can be chained
317    pub fn text_builder(content: String) -> PartBuilder {
318        PartBuilder::new_text(content)
319    }
320
321    /// Create a builder-style data part that can be chained
322    pub fn data_builder(data: Map<String, Value>) -> PartBuilder {
323        PartBuilder::new_data(data)
324    }
325
326    /// Create a builder-style file part that can be chained
327    pub fn file_builder() -> FilePartBuilder {
328        FilePartBuilder::new()
329    }
330}
331
332/// Builder for Part instances
333pub struct PartBuilder {
334    part: Part,
335}
336
337impl PartBuilder {
338    fn new_text(content: String) -> Self {
339        Self {
340            part: Part::Text {
341                text: content,
342                metadata: None,
343            },
344        }
345    }
346
347    fn new_data(data: Map<String, Value>) -> Self {
348        Self {
349            part: Part::Data {
350                data,
351                metadata: None,
352            },
353        }
354    }
355
356    /// Add metadata to any part type
357    pub fn with_metadata(mut self, metadata: Map<String, Value>) -> Self {
358        match &mut self.part {
359            Part::Text { metadata: meta, .. } => *meta = Some(metadata),
360            Part::Data { metadata: meta, .. } => *meta = Some(metadata),
361            Part::File { metadata: meta, .. } => *meta = Some(metadata),
362        }
363        self
364    }
365
366    /// Build the final Part
367    pub fn build(self) -> Part {
368        self.part
369    }
370}
371
372/// Builder for file parts with validation
373pub struct FilePartBuilder {
374    name: Option<String>,
375    mime_type: Option<String>,
376    bytes: Option<String>,
377    uri: Option<String>,
378    metadata: Option<Map<String, Value>>,
379}
380
381impl FilePartBuilder {
382    fn new() -> Self {
383        Self {
384            name: None,
385            mime_type: None,
386            bytes: None,
387            uri: None,
388            metadata: None,
389        }
390    }
391
392    /// Set the file name
393    pub fn name(mut self, name: String) -> Self {
394        self.name = Some(name);
395        self
396    }
397
398    /// Set the MIME type
399    pub fn mime_type(mut self, mime_type: String) -> Self {
400        self.mime_type = Some(mime_type);
401        self
402    }
403
404    /// Set file content as base64 bytes
405    pub fn bytes(mut self, bytes: String) -> Self {
406        self.bytes = Some(bytes);
407        self.uri = None; // Clear URI if setting bytes
408        self
409    }
410
411    /// Set file URI
412    pub fn uri(mut self, uri: String) -> Self {
413        self.uri = Some(uri);
414        self.bytes = None; // Clear bytes if setting URI
415        self
416    }
417
418    /// Add metadata
419    pub fn with_metadata(mut self, metadata: Map<String, Value>) -> Self {
420        self.metadata = Some(metadata);
421        self
422    }
423
424    /// Build the file part with validation
425    pub fn build(self) -> Result<Part, A2AError> {
426        let file_content = FileContent {
427            name: self.name,
428            mime_type: self.mime_type,
429            bytes: self.bytes,
430            uri: self.uri,
431        };
432
433        // Validate the file content
434        file_content.validate()?;
435
436        Ok(Part::File {
437            file: file_content,
438            metadata: self.metadata,
439        })
440    }
441}
442
443/// Helper methods for creating messages
444impl Message {
445    /// Create a new user message with a single text part
446    pub fn user_text(text: String, message_id: String) -> Self {
447        Self {
448            role: Role::User,
449            parts: vec![Part::text(text)],
450            metadata: None,
451            reference_task_ids: None,
452            message_id,
453            task_id: None,
454            context_id: None,
455            extensions: None,
456            kind: "message".to_string(),
457        }
458    }
459
460    /// Create a new agent message with a single text part
461    pub fn agent_text(text: String, message_id: String) -> Self {
462        Self {
463            role: Role::Agent,
464            parts: vec![Part::text(text)],
465            metadata: None,
466            reference_task_ids: None,
467            message_id,
468            task_id: None,
469            context_id: None,
470            extensions: None,
471            kind: "message".to_string(),
472        }
473    }
474
475    /// Add a part to this message
476    pub fn add_part(&mut self, part: Part) {
477        // If it's a file part, validate the file content
478        if let Part::File { file, .. } = &part {
479            // In debug mode, we'll assert that the file content is valid
480            debug_assert!(
481                file.validate().is_ok(),
482                "Invalid file content in Part::File"
483            );
484        }
485
486        self.parts.push(part);
487    }
488
489    /// Add a part to this message, validating and returning Result
490    #[cfg_attr(feature = "tracing", instrument(skip(self, part), fields(
491        message.id = %self.message_id
492    )))]
493    pub fn add_part_validated(&mut self, part: Part) -> Result<(), A2AError> {
494        // If it's a file part, validate the file content
495        if let Part::File { file, .. } = &part {
496            file.validate()?;
497        }
498
499        #[cfg(feature = "tracing")]
500        {
501            let part_type = match &part {
502                Part::Text { .. } => "text",
503                Part::File { .. } => "file",
504                Part::Data { .. } => "data",
505            };
506            tracing::debug!(part_type = part_type, "Part added successfully to message");
507        }
508
509        self.parts.push(part);
510        Ok(())
511    }
512
513    /// Validate a message (useful after building with builder)
514    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(
515        message.id = %self.message_id,
516        message.role = ?self.role,
517        message.parts_count = self.parts.len(),
518        message.kind = %self.kind
519    )))]
520    pub fn validate(&self) -> Result<(), A2AError> {
521        #[cfg(feature = "tracing")]
522        tracing::debug!("Validating message");
523
524        // Validate all file parts
525        for (index, part) in self.parts.iter().enumerate() {
526            if let Part::File { file, .. } = part {
527                #[cfg(feature = "tracing")]
528                tracing::trace!("Validating file part at index {}", index);
529                file.validate()?;
530            }
531        }
532
533        // Validate that kind is "message"
534        if self.kind != "message" {
535            #[cfg(feature = "tracing")]
536            tracing::error!("Invalid message kind: {}", self.kind);
537            return Err(A2AError::InvalidParams(
538                "Message kind must be 'message'".to_string(),
539            ));
540        }
541
542        #[cfg(feature = "tracing")]
543        tracing::debug!("Message validation successful");
544        Ok(())
545    }
546}