Skip to main content

klieo_a2a/
types.rs

1//! A2A v1.0 wire types — `Part`, `Message`, `Artifact`, `Task`, `AgentCard`.
2//!
3//! Field names are verbatim from the A2A v1.0 spec §6 to preserve
4//! wire-compatibility with non-NATS A2A clients. See
5//! <https://a2a-protocol.org/latest/specification/>.
6
7#![allow(non_snake_case)] // Spec field names are camelCase.
8
9use serde::{Deserialize, Serialize};
10
11/// Author role of a message — A2A spec §6.3.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Role {
15    /// Sent by the human / calling system.
16    User,
17    /// Sent by the agent.
18    Agent,
19}
20
21/// Lifecycle states of a long-running task — A2A spec §6.5.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum TaskStatus {
26    /// Task accepted, not yet started.
27    Submitted,
28    /// Task is executing.
29    Working,
30    /// Task is paused awaiting more user input.
31    InputRequired,
32    /// Task succeeded.
33    Completed,
34    /// Task ended with an error.
35    Failed,
36    /// Task was cancelled by the caller.
37    Canceled,
38    /// Task was rejected by the server before starting.
39    Rejected,
40    /// Task is waiting on authentication.
41    Authenticated,
42    /// An unrecognized status from a newer peer. Deserialization maps any
43    /// unknown wire value here instead of failing, so a forward-version peer
44    /// can't break parsing. Never terminal.
45    #[serde(other)]
46    Unknown,
47}
48
49impl TaskStatus {
50    /// `true` if this status cannot transition further. Stream
51    /// consumers close after observing a terminal status.
52    pub fn is_terminal(&self) -> bool {
53        matches!(
54            self,
55            TaskStatus::Completed
56                | TaskStatus::Failed
57                | TaskStatus::Canceled
58                | TaskStatus::Rejected
59        )
60    }
61}
62
63/// One element of a multi-part payload — A2A spec §6.2.
64///
65/// Tagged enum on `"type"`. Optional `metadata`, `filename`, and
66/// `mediaType` are present on every variant per the spec.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(tag = "type", rename_all = "lowercase")]
69#[non_exhaustive]
70pub enum Part {
71    /// UTF-8 text part.
72    Text {
73        /// The text body.
74        content: String,
75        /// Free-form metadata attached to the part.
76        #[serde(default, skip_serializing_if = "Option::is_none")]
77        metadata: Option<serde_json::Value>,
78        /// IANA media type hint (rarely used for text).
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        mediaType: Option<String>,
81        /// Suggested filename when extracting the part.
82        #[serde(default, skip_serializing_if = "Option::is_none")]
83        filename: Option<String>,
84    },
85    /// Inline binary blob, base64-encoded.
86    Raw {
87        /// Base64-encoded bytes.
88        bytes: String,
89        /// IANA media type of the bytes.
90        mediaType: String,
91        /// Free-form metadata.
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        metadata: Option<serde_json::Value>,
94        /// Suggested filename.
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        filename: Option<String>,
97    },
98    /// External resource reference.
99    Url {
100        /// URL to fetch.
101        url: String,
102        /// IANA media type hint.
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        mediaType: Option<String>,
105        /// Free-form metadata.
106        #[serde(default, skip_serializing_if = "Option::is_none")]
107        metadata: Option<serde_json::Value>,
108        /// Suggested filename.
109        #[serde(default, skip_serializing_if = "Option::is_none")]
110        filename: Option<String>,
111    },
112    /// Arbitrary structured JSON.
113    Data {
114        /// The JSON value.
115        data: serde_json::Value,
116        /// Free-form metadata.
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        metadata: Option<serde_json::Value>,
119        /// IANA media type hint (rarely used).
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        mediaType: Option<String>,
122        /// Suggested filename.
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        filename: Option<String>,
125    },
126    /// An unrecognized part type from a newer peer. Deserialization maps any
127    /// unknown `"type"` here instead of failing, preserving forward-compat.
128    #[serde(other)]
129    Unknown,
130}
131
132/// One message exchanged between user and agent — A2A spec §6.3.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Message {
135    /// Stable id for this message.
136    pub messageId: String,
137    /// Conversation/context id grouping related messages.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub contextId: Option<String>,
140    /// Optional task id this message participates in.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub taskId: Option<String>,
143    /// Author of the message.
144    pub role: Role,
145    /// Parts that make up the message.
146    pub parts: Vec<Part>,
147    /// Free-form metadata.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub metadata: Option<serde_json::Value>,
150    /// Extension URIs the message uses.
151    #[serde(default)]
152    pub extensions: Vec<String>,
153    /// Tasks this message references but does not own.
154    #[serde(default)]
155    pub referenceTaskIds: Vec<String>,
156}
157
158/// Output produced by an agent run — A2A spec §6.4.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Artifact {
161    /// Stable id for this artifact.
162    pub artifactId: String,
163    /// Optional human-readable name.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub name: Option<String>,
166    /// Optional description.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub description: Option<String>,
169    /// Parts that make up the artifact.
170    pub parts: Vec<Part>,
171    /// Free-form metadata.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub metadata: Option<serde_json::Value>,
174    /// Extension URIs the artifact uses.
175    #[serde(default)]
176    pub extensions: Vec<String>,
177}
178
179/// Long-running task — A2A spec §6.5.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct Task {
182    /// Stable id for this task.
183    pub id: String,
184    /// Conversation/context id this task belongs to.
185    pub contextId: String,
186    /// Lifecycle status.
187    pub status: TaskStatus,
188    /// Artifacts produced by the task.
189    #[serde(default)]
190    pub artifacts: Vec<Artifact>,
191    /// Message history of the task.
192    #[serde(default)]
193    pub history: Vec<Message>,
194    /// Free-form metadata.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub metadata: Option<serde_json::Value>,
197}
198
199/// Provider information for an agent — A2A spec §6.1.2.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct AgentProvider {
202    /// Organisation name.
203    pub organization: String,
204    /// Optional contact URL.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub url: Option<String>,
207}
208
209/// Capabilities the agent supports — A2A spec §6.1.3.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct AgentCapabilities {
212    /// Supports `SendStreamingMessage`.
213    #[serde(default)]
214    pub streaming: bool,
215    /// Supports push-notification config ops.
216    #[serde(default)]
217    pub pushNotifications: bool,
218    /// Records state transitions in task history.
219    #[serde(default)]
220    pub stateTransitionHistory: bool,
221}
222
223/// Skill an agent advertises — A2A spec §6.1.4.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct AgentSkill {
226    /// Stable skill id.
227    pub id: String,
228    /// Human-readable name.
229    pub name: String,
230    /// Free-form description.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub description: Option<String>,
233    /// Tags for discovery.
234    #[serde(default)]
235    pub tags: Vec<String>,
236}
237
238/// Transport interface an agent exposes — A2A spec §6.1.5.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct AgentInterface {
241    /// Transport identifier (e.g. `"a2a/1.0/jsonrpc"` or klieo-specific
242    /// `"a2a/1.0/nats"`).
243    pub transport: String,
244    /// URL or NATS subject prefix the transport listens on.
245    pub url: String,
246}
247
248/// Detached signature for an `AgentCard` — A2A spec §6.1.7.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct AgentCardSignature {
251    /// Algorithm identifier (e.g. `"ed25519"`).
252    pub alg: String,
253    /// Key id used.
254    pub kid: String,
255    /// Base64-encoded signature bytes.
256    pub signature: String,
257}
258
259/// Public description of an agent — A2A spec §6.1.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct AgentCard {
262    /// Stable agent id.
263    pub id: String,
264    /// Display name.
265    pub name: String,
266    /// Free-form description.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub description: Option<String>,
269    /// Provider org info.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub provider: Option<AgentProvider>,
272    /// Supported capabilities.
273    pub capabilities: AgentCapabilities,
274    /// Advertised skills.
275    #[serde(default)]
276    pub skills: Vec<AgentSkill>,
277    /// Transport interfaces.
278    #[serde(default)]
279    pub interfaces: Vec<AgentInterface>,
280    /// Security schemes (OpenAPI-style).
281    #[serde(default)]
282    pub securitySchemes: serde_json::Map<String, serde_json::Value>,
283    /// Required security scheme references.
284    #[serde(default)]
285    pub security: Vec<serde_json::Value>,
286    /// Extension URIs the agent uses.
287    #[serde(default)]
288    pub extensions: Vec<String>,
289    /// Optional detached signature.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub signature: Option<AgentCardSignature>,
292}
293
294/// `SendMessage` params — A2A spec §9.4.1.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct SendMessageParams {
297    /// The message to send.
298    pub message: Message,
299    /// Optional configuration knobs (per-call timeout, etc).
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub configuration: Option<serde_json::Value>,
302}
303
304/// `SendMessage` result.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(untagged)]
307pub enum SendMessageResult {
308    /// Server completed synchronously and returned a response message.
309    Message(Message),
310    /// Server kicked off a long-running task.
311    Task(Task),
312}
313
314/// `GetTask` params — A2A spec §9.4.3.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct GetTaskParams {
317    /// Task id to fetch.
318    pub id: String,
319    /// Optional history truncation depth.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub historyLength: Option<usize>,
322}
323
324/// `ListTasks` params — A2A spec §9.4.4.
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct ListTasksParams {
327    /// Optional context filter.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub contextId: Option<String>,
330    /// Optional pagination cursor.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub cursor: Option<String>,
333    /// Optional page size.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub limit: Option<usize>,
336}
337
338/// `ListTasks` result.
339#[derive(Debug, Clone, Default, Serialize, Deserialize)]
340pub struct ListTasksResult {
341    /// Page of tasks.
342    pub tasks: Vec<Task>,
343    /// Cursor to use for the next page, if more remain.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub nextCursor: Option<String>,
346}
347
348/// `CancelTask` params — A2A spec §9.4.5.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct CancelTaskParams {
351    /// Task id to cancel.
352    pub id: String,
353}
354
355/// `SubscribeToTask` params — A2A spec §9.4.6.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct SubscribeToTaskParams {
358    /// Task id to subscribe to.
359    pub id: String,
360}
361
362/// `CreateTaskPushNotificationConfig` params — A2A spec §9.4.7.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct PushNotificationConfigParams {
365    /// Task id.
366    pub taskId: String,
367    /// Webhook URL.
368    pub url: String,
369    /// Optional bearer token.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub token: Option<String>,
372}
373
374/// Stored push-notification config.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct PushNotificationConfig {
377    /// Stable config id.
378    pub id: String,
379    /// Owning task id.
380    pub taskId: String,
381    /// Webhook URL.
382    pub url: String,
383}
384
385/// `GetTaskPushNotificationConfig` params — A2A spec §9.4.8.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct GetPushNotificationConfigParams {
388    /// Task id.
389    pub taskId: String,
390    /// Config id.
391    pub id: String,
392}
393
394/// `ListTaskPushNotificationConfigs` params — A2A spec §9.4.9.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct ListPushNotificationConfigsParams {
397    /// Task id.
398    pub taskId: String,
399}
400
401/// `ListTaskPushNotificationConfigs` result.
402#[derive(Debug, Clone, Default, Serialize, Deserialize)]
403pub struct ListPushNotificationConfigsResult {
404    /// Stored configs for the task.
405    pub configs: Vec<PushNotificationConfig>,
406}
407
408/// `DeleteTaskPushNotificationConfig` params — A2A spec §9.4.10.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct DeletePushNotificationConfigParams {
411    /// Task id.
412    pub taskId: String,
413    /// Config id.
414    pub id: String,
415}
416
417/// `GetExtendedAgentCard` params — A2A spec §9.4.11. Intentionally empty per spec.
418#[derive(Debug, Clone, Default, Serialize, Deserialize)]
419pub struct GetExtendedAgentCardParams {}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use serde_json::json;
425
426    #[test]
427    fn unknown_task_status_deserialises_to_unknown_not_error() {
428        let s: TaskStatus = serde_json::from_value(json!("some_future_state")).unwrap();
429        assert_eq!(s, TaskStatus::Unknown);
430        assert!(!s.is_terminal(), "Unknown status is never terminal");
431    }
432
433    #[test]
434    fn unknown_part_type_deserialises_to_unknown_not_error() {
435        let p: Part = serde_json::from_value(json!({"type": "future_part", "x": 1})).unwrap();
436        assert!(matches!(p, Part::Unknown));
437    }
438
439    #[test]
440    fn part_text_serialises_with_type_text() {
441        let p = Part::Text {
442            content: "hello".into(),
443            metadata: None,
444            mediaType: None,
445            filename: None,
446        };
447        let v = serde_json::to_value(&p).unwrap();
448        assert_eq!(v["type"], "text");
449        assert_eq!(v["content"], "hello");
450    }
451
452    #[test]
453    fn part_raw_uses_base64_string_field() {
454        let p = Part::Raw {
455            bytes: "aGVsbG8=".into(),
456            mediaType: "text/plain".into(),
457            metadata: None,
458            filename: None,
459        };
460        let v = serde_json::to_value(&p).unwrap();
461        assert_eq!(v["type"], "raw");
462        assert_eq!(v["bytes"], "aGVsbG8=");
463        assert_eq!(v["mediaType"], "text/plain");
464    }
465
466    #[test]
467    fn message_round_trips() {
468        let m = Message {
469            messageId: "m-1".into(),
470            contextId: Some("c-1".into()),
471            taskId: Some("t-1".into()),
472            role: Role::User,
473            parts: vec![Part::Text {
474                content: "hi".into(),
475                metadata: None,
476                mediaType: None,
477                filename: None,
478            }],
479            metadata: None,
480            extensions: vec![],
481            referenceTaskIds: vec![],
482        };
483        let v = serde_json::to_value(&m).unwrap();
484        let back: Message = serde_json::from_value(v).unwrap();
485        assert_eq!(back.messageId, "m-1");
486        assert_eq!(back.role, Role::User);
487        assert_eq!(back.parts.len(), 1);
488    }
489
490    #[test]
491    fn task_with_empty_history_serialises_minimally() {
492        let t = Task {
493            id: "t-1".into(),
494            contextId: "c-1".into(),
495            status: TaskStatus::Submitted,
496            artifacts: vec![],
497            history: vec![],
498            metadata: None,
499        };
500        let v = serde_json::to_value(&t).unwrap();
501        assert_eq!(v["id"], "t-1");
502        assert_eq!(v["status"], "submitted");
503    }
504
505    #[test]
506    fn agent_card_round_trips() {
507        let card = AgentCard {
508            id: "agent-1".into(),
509            name: "Echo".into(),
510            description: Some("Echoes input".into()),
511            provider: None,
512            capabilities: AgentCapabilities {
513                streaming: false,
514                pushNotifications: false,
515                stateTransitionHistory: false,
516            },
517            skills: vec![],
518            interfaces: vec![],
519            securitySchemes: serde_json::Map::new(),
520            security: vec![],
521            extensions: vec![],
522            signature: None,
523        };
524        let v = serde_json::to_value(&card).unwrap();
525        let back: AgentCard = serde_json::from_value(v).unwrap();
526        assert_eq!(back.id, "agent-1");
527    }
528
529    #[test]
530    fn role_agent_serialises_as_lowercase() {
531        let v = serde_json::to_value(Role::Agent).unwrap();
532        assert_eq!(v, json!("agent"));
533    }
534
535    #[test]
536    fn task_status_is_terminal_covers_completed_failed_canceled_rejected() {
537        assert!(TaskStatus::Completed.is_terminal());
538        assert!(TaskStatus::Failed.is_terminal());
539        assert!(TaskStatus::Canceled.is_terminal());
540        assert!(TaskStatus::Rejected.is_terminal());
541    }
542
543    #[test]
544    fn task_status_is_terminal_excludes_in_progress() {
545        assert!(!TaskStatus::Submitted.is_terminal());
546        assert!(!TaskStatus::Working.is_terminal());
547        assert!(!TaskStatus::Authenticated.is_terminal());
548        assert!(!TaskStatus::InputRequired.is_terminal());
549    }
550}