Skip to main content

rust_web_server/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
211pub(crate) fn quote(s: &str) -> String {
212    let mut out = String::with_capacity(s.len() + 2);
213    out.push('"');
214    for c in s.chars() {
215        match c {
216            '"' => out.push_str("\\\""),
217            '\\' => out.push_str("\\\\"),
218            '\n' => out.push_str("\\n"),
219            '\r' => out.push_str("\\r"),
220            '\t' => out.push_str("\\t"),
221            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
222            c => out.push(c),
223        }
224    }
225    out.push('"');
226    out
227}
228
229/// Builds a JSON object field by field, leaving out absent ones.
230pub(crate) struct Obj(String);
231
232impl Obj {
233    pub(crate) fn new() -> Obj {
234        Obj(String::from("{"))
235    }
236
237    fn key(&mut self, key: &str) {
238        if self.0.len() > 1 {
239            self.0.push(',');
240        }
241        self.0.push_str(&quote(key));
242        self.0.push(':');
243    }
244
245    pub(crate) fn str(mut self, key: &str, value: &str) -> Obj {
246        self.key(key);
247        self.0.push_str(&quote(value));
248        self
249    }
250
251    pub(crate) fn opt_str(self, key: &str, value: Option<&str>) -> Obj {
252        match value {
253            Some(v) => self.str(key, v),
254            None => self,
255        }
256    }
257
258    /// Already-serialized JSON.
259    pub(crate) fn raw(mut self, key: &str, json: &str) -> Obj {
260        self.key(key);
261        self.0.push_str(json);
262        self
263    }
264
265    pub(crate) fn opt_raw(self, key: &str, json: Option<&str>) -> Obj {
266        match json {
267            Some(j) => self.raw(key, j),
268            None => self,
269        }
270    }
271
272    pub(crate) fn list(self, key: &str, items: &[String]) -> Obj {
273        let json = format!("[{}]", items.join(","));
274        self.raw(key, &json)
275    }
276
277    pub(crate) fn strs(self, key: &str, items: &[String]) -> Obj {
278        let quoted: Vec<String> = items.iter().map(|s| quote(s)).collect();
279        self.list(key, &quoted)
280    }
281
282    pub(crate) fn done(mut self) -> String {
283        self.0.push('}');
284        self.0
285    }
286}
287
288impl Part {
289    pub(crate) fn to_json(&self) -> String {
290        let o = match &self.content {
291            PartContent::Text(t) => Obj::new().str("text", t),
292            PartContent::Raw(b) => Obj::new().str("raw", &crate::core::base64::Base64::encode(b).unwrap_or_default()),
293            PartContent::Url(u) => Obj::new().str("url", u),
294            PartContent::Data(d) => Obj::new().raw("data", d),
295        };
296        o.opt_str("mediaType", self.media_type.as_deref()).opt_str("filename", self.filename.as_deref()).opt_raw("metadata", self.metadata.as_deref()).done()
297    }
298}
299
300impl Message {
301    pub(crate) fn to_json(&self) -> String {
302        let parts: Vec<String> = self.parts.iter().map(Part::to_json).collect();
303        let mut o = Obj::new()
304            .str("messageId", &self.message_id)
305            .opt_str("contextId", self.context_id.as_deref())
306            .opt_str("taskId", self.task_id.as_deref())
307            .str("role", self.role.as_str())
308            .list("parts", &parts)
309            .opt_raw("metadata", self.metadata.as_deref());
310        if !self.extensions.is_empty() {
311            o = o.strs("extensions", &self.extensions);
312        }
313        if !self.reference_task_ids.is_empty() {
314            o = o.strs("referenceTaskIds", &self.reference_task_ids);
315        }
316        o.done()
317    }
318}
319
320impl TaskStatus {
321    pub(crate) fn to_json(&self) -> String {
322        let message = self.message.as_ref().map(Message::to_json);
323        Obj::new().str("state", self.state.as_str()).opt_raw("message", message.as_deref()).str("timestamp", &self.timestamp).done()
324    }
325}
326
327impl Artifact {
328    pub(crate) fn to_json(&self) -> String {
329        let parts: Vec<String> = self.parts.iter().map(Part::to_json).collect();
330        Obj::new()
331            .str("artifactId", &self.artifact_id)
332            .opt_str("name", self.name.as_deref())
333            .opt_str("description", self.description.as_deref())
334            .list("parts", &parts)
335            .opt_raw("metadata", self.metadata.as_deref())
336            .done()
337    }
338}
339
340impl Task {
341    /// The task with at most `history_length` most recent messages
342    /// (`None`: all of them).
343    pub(crate) fn to_json(&self, history_length: Option<usize>) -> String {
344        let keep = history_length.unwrap_or(usize::MAX).min(self.history.len());
345        let history: Vec<String> = self.history[self.history.len() - keep..].iter().map(Message::to_json).collect();
346        let artifacts: Vec<String> = self.artifacts.iter().map(Artifact::to_json).collect();
347        let mut o = Obj::new().str("id", &self.id).str("contextId", &self.context_id).raw("status", &self.status.to_json());
348        if !artifacts.is_empty() {
349            o = o.list("artifacts", &artifacts);
350        }
351        if !history.is_empty() {
352            o = o.list("history", &history);
353        }
354        o.opt_raw("metadata", self.metadata.as_deref()).done()
355    }
356}
357
358/// A JSON value back to JSON text.
359pub(crate) fn to_json(value: &JsonValue) -> String {
360    match value {
361        JsonValue::Null => "null".to_string(),
362        JsonValue::Bool(b) => b.to_string(),
363        JsonValue::Number(n) if n.fract() == 0.0 && n.abs() < 9.0e15 => format!("{}", *n as i64),
364        JsonValue::Number(n) => format!("{n}"),
365        JsonValue::String(s) => quote(s),
366        JsonValue::Array(items) => format!("[{}]", items.iter().map(to_json).collect::<Vec<_>>().join(",")),
367        JsonValue::Object(pairs) => format!("{{{}}}", pairs.iter().map(|(k, v)| format!("{}:{}", quote(k), to_json(v))).collect::<Vec<_>>().join(",")),
368    }
369}
370
371// ── JSON in ──────────────────────────────────────────────────────────────────
372
373/// A field by its lowerCamelCase name or its proto (snake_case) name:
374/// ProtoJSON parsers accept both, and clients send either.
375pub(crate) fn field<'a>(value: &'a JsonValue, camel: &str) -> Option<&'a JsonValue> {
376    value.get(camel).or_else(|| {
377        let mut snake = String::with_capacity(camel.len() + 4);
378        for c in camel.chars() {
379            if c.is_ascii_uppercase() {
380                snake.push('_');
381                snake.push(c.to_ascii_lowercase());
382            } else {
383                snake.push(c);
384            }
385        }
386        (snake != camel).then(|| value.get(&snake)).flatten()
387    })
388}
389
390fn string_field(value: &JsonValue, key: &str) -> Option<String> {
391    field(value, key).and_then(JsonValue::as_str).map(str::to_string)
392}
393
394fn object_field(value: &JsonValue, key: &str) -> Option<String> {
395    field(value, key).filter(|v| matches!(v, JsonValue::Object(_))).map(to_json)
396}
397
398fn strings(value: &JsonValue, key: &str) -> Vec<String> {
399    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()
400}
401
402impl Part {
403    pub(crate) fn from_json(value: &JsonValue) -> Result<Part, String> {
404        let present: Vec<&str> = ["text", "raw", "url", "data"].into_iter().filter(|k| value.get(k).is_some()).collect();
405        if present.len() != 1 {
406            return Err("a part must contain exactly one of text, raw, url, data".to_string());
407        }
408        let content = match present[0] {
409            "text" => PartContent::Text(string_field(value, "text").ok_or("part text must be a string")?),
410            "url" => PartContent::Url(string_field(value, "url").ok_or("part url must be a string")?),
411            "raw" => {
412                let encoded = string_field(value, "raw").ok_or("part raw must be a base64 string")?;
413                PartContent::Raw(crate::core::base64::Base64::decode(encoded).map_err(|_| "part raw isn't valid base64".to_string())?)
414            }
415            _ => PartContent::Data(to_json(value.get("data").unwrap_or(&JsonValue::Null))),
416        };
417        Ok(Part { content, media_type: string_field(value, "mediaType"), filename: string_field(value, "filename"), metadata: object_field(value, "metadata") })
418    }
419}
420
421impl Message {
422    pub(crate) fn from_json(value: &JsonValue) -> Result<Message, String> {
423        if !matches!(value, JsonValue::Object(_)) {
424            return Err("message must be an object".to_string());
425        }
426        let message_id = string_field(value, "messageId").filter(|s| !s.is_empty()).ok_or("message.messageId is required")?;
427        let role = match value.get("role").and_then(JsonValue::as_str) {
428            Some("ROLE_USER") | Some("user") => Role::User,
429            Some("ROLE_AGENT") | Some("agent") => Role::Agent,
430            _ => return Err("message.role must be ROLE_USER or ROLE_AGENT".to_string()),
431        };
432        let parts_json = value.get("parts").and_then(JsonValue::as_array).ok_or("message.parts is required")?;
433        if parts_json.is_empty() {
434            return Err("message.parts must not be empty".to_string());
435        }
436        let parts = parts_json.iter().map(Part::from_json).collect::<Result<Vec<_>, _>>()?;
437        Ok(Message {
438            message_id,
439            context_id: string_field(value, "contextId").filter(|s| !s.is_empty()),
440            task_id: string_field(value, "taskId").filter(|s| !s.is_empty()),
441            role,
442            parts,
443            metadata: object_field(value, "metadata"),
444            extensions: strings(value, "extensions"),
445            reference_task_ids: strings(value, "referenceTaskIds"),
446        })
447    }
448}
449
450/// ISO 8601 UTC with milliseconds (`2026-10-31T10:30:00.000Z`).
451pub(crate) fn timestamp() -> String {
452    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
453    let secs = now.as_secs();
454    let (days, rest) = ((secs / 86_400) as i64, secs % 86_400);
455    // Howard Hinnant's civil_from_days.
456    let z = days + 719_468;
457    let era = z.div_euclid(146_097);
458    let doe = z - era * 146_097;
459    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
460    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
461    let mp = (5 * doy + 2) / 153;
462    let day = doy - (153 * mp + 2) / 5 + 1;
463    let month = if mp < 10 { mp + 3 } else { mp - 9 };
464    let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 };
465    format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z", rest / 3600, rest % 3600 / 60, rest % 60, now.subsec_millis())
466}