Skip to main content

rws_proxy/a2a/
model.rs

1//! The A2A 1.0 data model and its JSON form (ProtoJSON: camelCase fields,
2//! enums as `TASK_STATE_*` / `ROLE_*` strings, no `kind` discriminator).
3
4use crate::service_discovery::json_lite::JsonValue;
5
6/// Who sent a message.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Role {
9    User,
10    Agent,
11}
12
13impl Role {
14    fn as_str(self) -> &'static str {
15        match self {
16            Role::User => "ROLE_USER",
17            Role::Agent => "ROLE_AGENT",
18        }
19    }
20}
21
22/// One piece of a message or artifact: exactly one of text, raw bytes, a
23/// URL or structured data, with an optional media type and file name.
24#[derive(Debug, Clone, PartialEq)]
25pub struct Part {
26    pub content: PartContent,
27    pub media_type: Option<String>,
28    pub filename: Option<String>,
29    /// Raw JSON object, as sent.
30    pub metadata: Option<String>,
31}
32
33/// What a [`Part`] holds.
34#[derive(Debug, Clone, PartialEq)]
35pub enum PartContent {
36    Text(String),
37    /// Bytes (base64 on the wire).
38    Raw(Vec<u8>),
39    Url(String),
40    /// Any JSON value, kept as its JSON text.
41    Data(String),
42}
43
44impl Part {
45    pub fn text(text: impl Into<String>) -> Part {
46        Part { content: PartContent::Text(text.into()), media_type: None, filename: None, metadata: None }
47    }
48
49    /// Structured data from its JSON text (`{"total": 42}`).
50    pub fn data(json: impl Into<String>) -> Part {
51        Part { content: PartContent::Data(json.into()), media_type: Some("application/json".to_string()), filename: None, metadata: None }
52    }
53
54    pub fn raw(bytes: Vec<u8>, media_type: &str) -> Part {
55        Part { content: PartContent::Raw(bytes), media_type: Some(media_type.to_string()), filename: None, metadata: None }
56    }
57
58    pub fn url(url: impl Into<String>, media_type: &str) -> Part {
59        Part { content: PartContent::Url(url.into()), media_type: Some(media_type.to_string()), filename: None, metadata: None }
60    }
61
62    pub fn filename(mut self, name: impl Into<String>) -> Part {
63        self.filename = Some(name.into());
64        self
65    }
66
67    /// The text, when this is a text part.
68    pub fn as_text(&self) -> Option<&str> {
69        match &self.content {
70            PartContent::Text(t) => Some(t),
71            _ => None,
72        }
73    }
74}
75
76/// A message between a client and an agent.
77#[derive(Debug, Clone, PartialEq)]
78pub struct Message {
79    pub message_id: String,
80    pub context_id: Option<String>,
81    pub task_id: Option<String>,
82    pub role: Role,
83    pub parts: Vec<Part>,
84    pub metadata: Option<String>,
85    pub extensions: Vec<String>,
86    pub reference_task_ids: Vec<String>,
87}
88
89impl Message {
90    /// An agent message with one text part.
91    pub fn agent_text(text: impl Into<String>) -> Message {
92        Message::agent(vec![Part::text(text)])
93    }
94
95    /// An agent message.
96    pub fn agent(parts: Vec<Part>) -> Message {
97        Message {
98            message_id: super::new_id(),
99            context_id: None,
100            task_id: None,
101            role: Role::Agent,
102            parts,
103            metadata: None,
104            extensions: Vec::new(),
105            reference_task_ids: Vec::new(),
106        }
107    }
108
109    /// All text parts, joined by newlines.
110    pub fn text(&self) -> String {
111        self.parts.iter().filter_map(Part::as_text).collect::<Vec<_>>().join("\n")
112    }
113}
114
115/// A task's state.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum TaskState {
118    Submitted,
119    Working,
120    InputRequired,
121    AuthRequired,
122    Completed,
123    Failed,
124    Canceled,
125    Rejected,
126}
127
128impl TaskState {
129    pub fn as_str(self) -> &'static str {
130        match self {
131            TaskState::Submitted => "TASK_STATE_SUBMITTED",
132            TaskState::Working => "TASK_STATE_WORKING",
133            TaskState::InputRequired => "TASK_STATE_INPUT_REQUIRED",
134            TaskState::AuthRequired => "TASK_STATE_AUTH_REQUIRED",
135            TaskState::Completed => "TASK_STATE_COMPLETED",
136            TaskState::Failed => "TASK_STATE_FAILED",
137            TaskState::Canceled => "TASK_STATE_CANCELED",
138            TaskState::Rejected => "TASK_STATE_REJECTED",
139        }
140    }
141
142    pub fn parse(s: &str) -> Option<TaskState> {
143        [
144            TaskState::Submitted,
145            TaskState::Working,
146            TaskState::InputRequired,
147            TaskState::AuthRequired,
148            TaskState::Completed,
149            TaskState::Failed,
150            TaskState::Canceled,
151            TaskState::Rejected,
152        ]
153        .into_iter()
154        .find(|t| t.as_str() == s)
155    }
156
157    /// Completed, failed, canceled or rejected: nothing more happens.
158    pub fn is_terminal(self) -> bool {
159        matches!(self, TaskState::Completed | TaskState::Failed | TaskState::Canceled | TaskState::Rejected)
160    }
161
162    /// Waiting on the client (input or authentication).
163    pub fn is_interrupted(self) -> bool {
164        matches!(self, TaskState::InputRequired | TaskState::AuthRequired)
165    }
166}
167
168/// A task's state with an optional message and when it was set.
169#[derive(Debug, Clone, PartialEq)]
170pub struct TaskStatus {
171    pub state: TaskState,
172    pub message: Option<Message>,
173    /// ISO 8601 UTC.
174    pub timestamp: String,
175}
176
177/// An output of a task.
178#[derive(Debug, Clone, PartialEq)]
179pub struct Artifact {
180    pub artifact_id: String,
181    pub name: Option<String>,
182    pub description: Option<String>,
183    pub parts: Vec<Part>,
184    pub metadata: Option<String>,
185}
186
187impl Artifact {
188    pub fn new(name: impl Into<String>, parts: Vec<Part>) -> Artifact {
189        Artifact { artifact_id: super::new_id(), name: Some(name.into()), description: None, parts, metadata: None }
190    }
191
192    /// An artifact with one text part.
193    pub fn text(name: impl Into<String>, text: impl Into<String>) -> Artifact {
194        Artifact::new(name, vec![Part::text(text)])
195    }
196}
197
198/// A unit of work the agent tracks.
199#[derive(Debug, Clone, PartialEq)]
200pub struct Task {
201    pub id: String,
202    pub context_id: String,
203    pub status: TaskStatus,
204    pub artifacts: Vec<Artifact>,
205    pub history: Vec<Message>,
206    pub metadata: Option<String>,
207}
208
209// ── JSON out ─────────────────────────────────────────────────────────────────
210
211
212/// Builds a JSON object field by field, leaving out absent ones.
213#[doc(hidden)]
214pub struct Obj(String);
215
216impl Obj {
217    #[doc(hidden)]
218    pub fn new() -> Obj {
219        Obj(String::from("{"))
220    }
221
222    fn key(&mut self, key: &str) {
223        if self.0.len() > 1 {
224            self.0.push(',');
225        }
226        self.0.push_str(&quote(key));
227        self.0.push(':');
228    }
229
230    #[doc(hidden)]
231    pub fn str(mut self, key: &str, value: &str) -> Obj {
232        self.key(key);
233        self.0.push_str(&quote(value));
234        self
235    }
236
237    #[doc(hidden)]
238    pub fn opt_str(self, key: &str, value: Option<&str>) -> Obj {
239        match value {
240            Some(v) => self.str(key, v),
241            None => self,
242        }
243    }
244
245    /// Already-serialized JSON.
246    #[doc(hidden)]
247    pub fn raw(mut self, key: &str, json: &str) -> Obj {
248        self.key(key);
249        self.0.push_str(json);
250        self
251    }
252
253    #[doc(hidden)]
254    pub fn opt_raw(self, key: &str, json: Option<&str>) -> Obj {
255        match json {
256            Some(j) => self.raw(key, j),
257            None => self,
258        }
259    }
260
261    #[doc(hidden)]
262    pub fn list(self, key: &str, items: &[String]) -> Obj {
263        let json = format!("[{}]", items.join(","));
264        self.raw(key, &json)
265    }
266
267    #[doc(hidden)]
268    pub fn strs(self, key: &str, items: &[String]) -> Obj {
269        let quoted: Vec<String> = items.iter().map(|s| quote(s)).collect();
270        self.list(key, &quoted)
271    }
272
273    #[doc(hidden)]
274    pub fn done(mut self) -> String {
275        self.0.push('}');
276        self.0
277    }
278}
279
280impl Part {
281    #[doc(hidden)]
282    pub fn to_json(&self) -> String {
283        let o = match &self.content {
284            PartContent::Text(t) => Obj::new().str("text", t),
285            PartContent::Raw(b) => Obj::new().str("raw", &crate::core::base64::Base64::encode(b).unwrap_or_default()),
286            PartContent::Url(u) => Obj::new().str("url", u),
287            PartContent::Data(d) => Obj::new().raw("data", d),
288        };
289        o.opt_str("mediaType", self.media_type.as_deref()).opt_str("filename", self.filename.as_deref()).opt_raw("metadata", self.metadata.as_deref()).done()
290    }
291}
292
293impl Message {
294    #[doc(hidden)]
295    pub fn to_json(&self) -> String {
296        let parts: Vec<String> = self.parts.iter().map(Part::to_json).collect();
297        let mut o = Obj::new()
298            .str("messageId", &self.message_id)
299            .opt_str("contextId", self.context_id.as_deref())
300            .opt_str("taskId", self.task_id.as_deref())
301            .str("role", self.role.as_str())
302            .list("parts", &parts)
303            .opt_raw("metadata", self.metadata.as_deref());
304        if !self.extensions.is_empty() {
305            o = o.strs("extensions", &self.extensions);
306        }
307        if !self.reference_task_ids.is_empty() {
308            o = o.strs("referenceTaskIds", &self.reference_task_ids);
309        }
310        o.done()
311    }
312}
313
314impl TaskStatus {
315    #[doc(hidden)]
316    pub fn to_json(&self) -> String {
317        let message = self.message.as_ref().map(Message::to_json);
318        Obj::new().str("state", self.state.as_str()).opt_raw("message", message.as_deref()).str("timestamp", &self.timestamp).done()
319    }
320}
321
322impl Artifact {
323    #[doc(hidden)]
324    pub fn to_json(&self) -> String {
325        let parts: Vec<String> = self.parts.iter().map(Part::to_json).collect();
326        Obj::new()
327            .str("artifactId", &self.artifact_id)
328            .opt_str("name", self.name.as_deref())
329            .opt_str("description", self.description.as_deref())
330            .list("parts", &parts)
331            .opt_raw("metadata", self.metadata.as_deref())
332            .done()
333    }
334}
335
336impl Task {
337    /// The task with at most `history_length` most recent messages
338    /// (`None`: all of them).
339    #[doc(hidden)]
340    pub fn to_json(&self, history_length: Option<usize>) -> String {
341        let keep = history_length.unwrap_or(usize::MAX).min(self.history.len());
342        let history: Vec<String> = self.history[self.history.len() - keep..].iter().map(Message::to_json).collect();
343        let artifacts: Vec<String> = self.artifacts.iter().map(Artifact::to_json).collect();
344        let mut o = Obj::new().str("id", &self.id).str("contextId", &self.context_id).raw("status", &self.status.to_json());
345        if !artifacts.is_empty() {
346            o = o.list("artifacts", &artifacts);
347        }
348        if !history.is_empty() {
349            o = o.list("history", &history);
350        }
351        o.opt_raw("metadata", self.metadata.as_deref()).done()
352    }
353}
354
355// The JSON writer is shared with MCP's OpenAPI tools (`json_lite`).
356pub(crate) use crate::service_discovery::json_lite::{quote, to_text as to_json};
357
358// ── JSON in ──────────────────────────────────────────────────────────────────
359
360/// A field by its lowerCamelCase name or its proto (snake_case) name:
361/// ProtoJSON parsers accept both, and clients send either.
362#[doc(hidden)]
363pub fn field<'a>(value: &'a JsonValue, camel: &str) -> Option<&'a JsonValue> {
364    value.get(camel).or_else(|| {
365        let mut snake = String::with_capacity(camel.len() + 4);
366        for c in camel.chars() {
367            if c.is_ascii_uppercase() {
368                snake.push('_');
369                snake.push(c.to_ascii_lowercase());
370            } else {
371                snake.push(c);
372            }
373        }
374        (snake != camel).then(|| value.get(&snake)).flatten()
375    })
376}
377
378fn string_field(value: &JsonValue, key: &str) -> Option<String> {
379    field(value, key).and_then(JsonValue::as_str).map(str::to_string)
380}
381
382fn object_field(value: &JsonValue, key: &str) -> Option<String> {
383    field(value, key).filter(|v| matches!(v, JsonValue::Object(_))).map(to_json)
384}
385
386fn strings(value: &JsonValue, key: &str) -> Vec<String> {
387    field(value, key).and_then(JsonValue::as_array).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
388}
389
390impl Part {
391    #[doc(hidden)]
392    pub fn from_json(value: &JsonValue) -> Result<Part, String> {
393        let present: Vec<&str> = ["text", "raw", "url", "data"].into_iter().filter(|k| value.get(k).is_some()).collect();
394        if present.len() != 1 {
395            return Err("a part must contain exactly one of text, raw, url, data".to_string());
396        }
397        let content = match present[0] {
398            "text" => PartContent::Text(string_field(value, "text").ok_or("part text must be a string")?),
399            "url" => PartContent::Url(string_field(value, "url").ok_or("part url must be a string")?),
400            "raw" => {
401                let encoded = string_field(value, "raw").ok_or("part raw must be a base64 string")?;
402                PartContent::Raw(crate::core::base64::Base64::decode(encoded).map_err(|_| "part raw isn't valid base64".to_string())?)
403            }
404            _ => PartContent::Data(to_json(value.get("data").unwrap_or(&JsonValue::Null))),
405        };
406        Ok(Part { content, media_type: string_field(value, "mediaType"), filename: string_field(value, "filename"), metadata: object_field(value, "metadata") })
407    }
408}
409
410impl Message {
411    #[doc(hidden)]
412    pub fn from_json(value: &JsonValue) -> Result<Message, String> {
413        if !matches!(value, JsonValue::Object(_)) {
414            return Err("message must be an object".to_string());
415        }
416        let message_id = string_field(value, "messageId").filter(|s| !s.is_empty()).ok_or("message.messageId is required")?;
417        let role = match value.get("role").and_then(JsonValue::as_str) {
418            Some("ROLE_USER") | Some("user") => Role::User,
419            Some("ROLE_AGENT") | Some("agent") => Role::Agent,
420            _ => return Err("message.role must be ROLE_USER or ROLE_AGENT".to_string()),
421        };
422        let parts_json = value.get("parts").and_then(JsonValue::as_array).ok_or("message.parts is required")?;
423        if parts_json.is_empty() {
424            return Err("message.parts must not be empty".to_string());
425        }
426        let parts = parts_json.iter().map(Part::from_json).collect::<Result<Vec<_>, _>>()?;
427        Ok(Message {
428            message_id,
429            context_id: string_field(value, "contextId").filter(|s| !s.is_empty()),
430            task_id: string_field(value, "taskId").filter(|s| !s.is_empty()),
431            role,
432            parts,
433            metadata: object_field(value, "metadata"),
434            extensions: strings(value, "extensions"),
435            reference_task_ids: strings(value, "referenceTaskIds"),
436        })
437    }
438}
439
440/// ISO 8601 UTC with milliseconds (`2026-10-31T10:30:00.000Z`).
441#[doc(hidden)]
442pub fn timestamp() -> String {
443    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
444    let secs = now.as_secs();
445    let (days, rest) = ((secs / 86_400) as i64, secs % 86_400);
446    // Howard Hinnant's civil_from_days.
447    let z = days + 719_468;
448    let era = z.div_euclid(146_097);
449    let doe = z - era * 146_097;
450    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
451    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
452    let mp = (5 * doy + 2) / 153;
453    let day = doy - (153 * mp + 2) / 5 + 1;
454    let month = if mp < 10 { mp + 3 } else { mp - 9 };
455    let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 };
456    format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z", rest / 3600, rest % 3600 / 60, rest % 60, now.subsec_millis())
457}