Skip to main content

systemprompt_models/a2a/
message.rs

1//! A2A message and part wire types.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use serde::{Deserialize, Serialize};
7use systemprompt_identifiers::{ContextId, MessageId, TaskId};
8
9#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
10#[serde(rename_all = "camelCase")]
11#[expect(
12    clippy::struct_field_names,
13    reason = "A2A wire schema names these fields message_id/task_id/context_id — public protocol \
14              vocabulary"
15)]
16pub struct Message {
17    pub role: MessageRole,
18    pub parts: Vec<Part>,
19    pub message_id: MessageId,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub task_id: Option<TaskId>,
22    pub context_id: ContextId,
23    // JSON: A2A `Message.metadata` is spec-defined as a free-form object.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub extensions: Option<Vec<String>>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub reference_task_ids: Option<Vec<TaskId>>,
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
33pub enum MessageRole {
34    #[serde(rename = "ROLE_USER")]
35    User,
36    #[serde(rename = "ROLE_AGENT")]
37    Agent,
38}
39
40#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
41#[serde(rename_all = "camelCase", try_from = "PartWire")]
42pub struct Part {
43    #[serde(flatten)]
44    pub content: PartContent,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub filename: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub media_type: Option<String>,
49    // JSON: A2A `Part.metadata` is spec-defined as a free-form object.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
52}
53
54#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
55#[serde(rename_all = "lowercase")]
56pub enum PartContent {
57    Text(String),
58    Raw(String),
59    Url(String),
60    // JSON: A2A `Part.data` is spec-defined as an arbitrary JSON value.
61    Data(serde_json::Value),
62}
63
64#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
65pub enum PartContentError {
66    #[error("A2A part carries no content: exactly one of text, raw, url or data is required")]
67    Missing,
68    #[error(
69        "A2A part carries {count} content fields: exactly one of text, raw, url or data is allowed"
70    )]
71    Multiple { count: usize },
72}
73
74#[derive(Deserialize)]
75#[serde(rename_all = "camelCase")]
76struct PartWire {
77    #[serde(default)]
78    text: Option<String>,
79    #[serde(default)]
80    raw: Option<String>,
81    #[serde(default)]
82    url: Option<String>,
83    // JSON: A2A `Part.data` is spec-defined as an arbitrary JSON value, including null.
84    #[serde(default, deserialize_with = "present_value")]
85    data: Option<serde_json::Value>,
86    #[serde(default)]
87    filename: Option<String>,
88    #[serde(default)]
89    media_type: Option<String>,
90    // JSON: A2A `Part.metadata` is spec-defined as a free-form object.
91    #[serde(default)]
92    metadata: Option<serde_json::Map<String, serde_json::Value>>,
93}
94
95// JSON: a present `data` key holds an arbitrary JSON value, including null.
96fn present_value<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
97where
98    D: serde::Deserializer<'de>,
99{
100    serde_json::Value::deserialize(deserializer).map(Some)
101}
102
103impl TryFrom<PartWire> for Part {
104    type Error = PartContentError;
105
106    fn try_from(wire: PartWire) -> Result<Self, Self::Error> {
107        let mut contents = [
108            wire.text.map(PartContent::Text),
109            wire.raw.map(PartContent::Raw),
110            wire.url.map(PartContent::Url),
111            wire.data.map(PartContent::Data),
112        ]
113        .into_iter()
114        .flatten();
115        let content = contents.next().ok_or(PartContentError::Missing)?;
116        let extra = contents.count();
117        if extra > 0 {
118            return Err(PartContentError::Multiple { count: extra + 1 });
119        }
120        Ok(Self {
121            content,
122            filename: wire.filename,
123            media_type: wire.media_type,
124            metadata: wire.metadata,
125        })
126    }
127}
128
129impl Part {
130    const fn from_content(content: PartContent, media_type: Option<String>) -> Self {
131        Self {
132            content,
133            filename: None,
134            media_type,
135            metadata: None,
136        }
137    }
138
139    pub fn text(text: impl Into<String>) -> Self {
140        Self::from_content(PartContent::Text(text.into()), None)
141    }
142
143    // JSON: A2A `Part.data` is spec-defined as an arbitrary JSON value.
144    #[must_use]
145    pub const fn data(data: serde_json::Value) -> Self {
146        Self::from_content(PartContent::Data(data), None)
147    }
148
149    pub fn raw(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
150        Self::from_content(PartContent::Raw(base64.into()), Some(media_type.into()))
151    }
152
153    pub fn url(url: impl Into<String>, media_type: impl Into<String>) -> Self {
154        Self::from_content(PartContent::Url(url.into()), Some(media_type.into()))
155    }
156
157    #[must_use]
158    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
159        self.filename = Some(filename.into());
160        self
161    }
162
163    #[must_use]
164    pub fn with_media_type(mut self, media_type: impl Into<String>) -> Self {
165        self.media_type = Some(media_type.into());
166        self
167    }
168
169    #[must_use]
170    pub fn as_text(&self) -> Option<&str> {
171        match &self.content {
172            PartContent::Text(text) => Some(text),
173            _ => None,
174        }
175    }
176
177    // JSON: A2A `Part.data` is spec-defined as an arbitrary JSON value.
178    #[must_use]
179    pub const fn as_data(&self) -> Option<&serde_json::Value> {
180        match &self.content {
181            PartContent::Data(data) => Some(data),
182            _ => None,
183        }
184    }
185
186    #[must_use]
187    pub fn as_raw(&self) -> Option<&str> {
188        match &self.content {
189            PartContent::Raw(raw) => Some(raw),
190            _ => None,
191        }
192    }
193
194    #[must_use]
195    pub fn as_url(&self) -> Option<&str> {
196        match &self.content {
197            PartContent::Url(url) => Some(url),
198            _ => None,
199        }
200    }
201
202    #[must_use]
203    pub const fn is_file(&self) -> bool {
204        matches!(self.content, PartContent::Raw(_) | PartContent::Url(_))
205    }
206}