Skip to main content

lc_a2a/protocol/
model.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use super::message::A2AMessage;
7use super::task::{A2ATask, TaskStatus};
8
9pub(crate) fn default_protocol_version() -> String {
10    "0.3.0".to_string()
11}
12
13pub(crate) fn default_input_modes() -> Vec<String> {
14    vec!["text".to_string()]
15}
16
17pub(crate) fn default_output_modes() -> Vec<String> {
18    vec!["text".to_string()]
19}
20
21/// Result of a completed A2A task.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct A2ATaskResult {
24    /// Output text from the task.
25    pub output: String,
26}
27
28impl A2ATaskResult {
29    /// Create a new task result.
30    pub fn new(output: impl Into<String>) -> Self {
31        Self {
32            output: output.into(),
33        }
34    }
35}
36
37/// Detailed view of a task including its result and any error message.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct A2ATaskDetails {
40    /// The task itself.
41    pub task: A2ATask,
42    /// Result of the task (present when the task completed).
43    pub result: Option<A2ATaskResult>,
44    /// Error message (present when the task failed).
45    pub error: Option<String>,
46}
47
48/// A multi-step orchestration submitted via `tasks/runWorkflow` (P2-8).
49///
50/// Steps execute in order on the server; each step is an instruction (a
51/// message) that can be routed to a different skill/chain. Results are
52/// aggregated per step so the caller sees which output came from where.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct A2AWorkflow {
55    /// Caller-supplied workflow id, reused as the backing task id when present.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub workflow_id: Option<String>,
58    /// Optional human-readable name (advisory only).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub name: Option<String>,
61    /// Ordered steps to execute.
62    pub steps: Vec<WorkflowStep>,
63}
64
65impl A2AWorkflow {
66    /// Create a workflow from an ordered list of steps.
67    pub fn new(steps: Vec<WorkflowStep>) -> Self {
68        Self {
69            workflow_id: None,
70            name: None,
71            steps,
72        }
73    }
74
75    /// Attach a caller-supplied workflow id (reused as the backing task id).
76    pub fn with_workflow_id(mut self, id: impl Into<String>) -> Self {
77        self.workflow_id = Some(id.into());
78        self
79    }
80
81    /// Attach an advisory name.
82    pub fn with_name(mut self, name: impl Into<String>) -> Self {
83        self.name = Some(name.into());
84        self
85    }
86}
87
88/// A single unit of work within an [`A2AWorkflow`] (P2-8).
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct WorkflowStep {
91    /// Unique step identifier within its workflow; names the step's result.
92    pub id: String,
93    /// The instruction executed for this step.
94    pub message: A2AMessage,
95    /// Optional skill to route this step to a specialized chain (P2-4).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub skill_id: Option<String>,
98}
99
100impl WorkflowStep {
101    /// Create a step that runs `content` through the default chain.
102    pub fn new(id: impl Into<String>, content: impl Into<String>) -> Self {
103        Self {
104            id: id.into(),
105            message: A2AMessage::user(content),
106            skill_id: None,
107        }
108    }
109
110    /// Create a step routed to a specific skill (P2-4).
111    pub fn with_skill(
112        id: impl Into<String>,
113        content: impl Into<String>,
114        skill_id: impl Into<String>,
115    ) -> Self {
116        Self {
117            id: id.into(),
118            message: A2AMessage::user(content),
119            skill_id: Some(skill_id.into()),
120        }
121    }
122}
123
124/// A2A JSON-RPC style request.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct A2ARequest {
127    /// JSON-RPC version.
128    pub jsonrpc: String,
129    /// Request identifier.
130    pub id: u64,
131    /// Method name (e.g. "tasks/send", "tasks/get").
132    pub method: String,
133    /// Method parameters.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub params: Option<Value>,
136    /// Optional metadata (P1-5 / P2-8).
137    ///
138    /// Carries the W3C `trace_id`, the caller/`owner` identity, and other
139    /// cross-cutting data without polluting the method-specific params.
140    /// Standard well-known keys (read via the accessor helpers):
141    /// - `trace_id` — W3C-style trace id for distributed tracing (P1-5);
142    /// - `owner` — caller identity used for task ownership authorization (P1-4);
143    /// - `message_id` — idempotency key for `tasks/send` (P1-6).
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub metadata: Option<Value>,
146}
147
148/// Standard well-known metadata keys.
149pub mod metadata_keys {
150    /// W3C-style trace id (P1-5 / P2-8).
151    pub const TRACE_ID: &str = "trace_id";
152    /// Caller / organization identity (P1-4).
153    pub const OWNER: &str = "owner";
154    /// Idempotency key for `tasks/send` (P1-6).
155    pub const MESSAGE_ID: &str = "message_id";
156}
157
158impl A2ARequest {
159    /// Create a new request.
160    pub fn new(id: u64, method: impl Into<String>, params: Option<Value>) -> Self {
161        Self {
162            jsonrpc: "2.0".to_string(),
163            id,
164            method: method.into(),
165            params,
166            metadata: None,
167        }
168    }
169
170    /// Create a `tasks/send` request.
171    pub fn send_task(id: u64, message: &A2AMessage) -> Self {
172        let params = serde_json::to_value(message)
173            .ok()
174            .map(|v| serde_json::json!({ "message": v }));
175        Self::new(id, "tasks/send", params)
176    }
177
178    /// Create a `tasks/send` request with an idempotency key (P1-6).
179    ///
180    /// Re-sending the same `message_id` makes the server return the already
181    /// created task instead of running the chain twice.
182    pub fn send_task_with_message_id(id: u64, message: &A2AMessage, message_id: &str) -> Self {
183        Self::send_task(id, message).with_message_id(message_id)
184    }
185
186    /// Create a `tasks/send` request from a [`MessageEnvelope`], propagating
187    /// its owner and trace context into request metadata (P2-8).
188    ///
189    /// Lets a transport-neutral envelope be handed to the JSON-RPC HTTP layer
190    /// without losing the cross-cutting metadata it carries.
191    pub fn send_envelope(id: u64, envelope: &MessageEnvelope) -> Self {
192        let mut req = Self::send_task(id, &envelope.message);
193        if let Some(owner) = &envelope.owner {
194            req = req.with_owner(owner);
195        }
196        if let Some(trace) = &envelope.trace {
197            req = req.with_trace_id(trace.trace_id.as_str());
198        }
199        req
200    }
201
202    /// Create a `tasks/send` request that continues an existing task (P2-2/P2-3).
203    ///
204    /// Used to resume an `input-required` task or append a new turn to a
205    /// multi-turn conversation: the server appends `message` to the task's
206    /// history and (re)starts processing.
207    pub fn continue_task(id: u64, task_id: &str, message: &A2AMessage) -> Self {
208        let params = serde_json::to_value(message)
209            .ok()
210            .map(|v| serde_json::json!({ "taskId": task_id, "message": v }));
211        Self::new(id, "tasks/send", params)
212    }
213
214    /// Create a `tasks/get` request.
215    pub fn get_task(id: u64, task_id: &str) -> Self {
216        Self::new(
217            id,
218            "tasks/get",
219            Some(serde_json::json!({ "taskId": task_id })),
220        )
221    }
222
223    /// Create a `tasks/cancel` request.
224    pub fn cancel_task(id: u64, task_id: &str) -> Self {
225        Self::new(
226            id,
227            "tasks/cancel",
228            Some(serde_json::json!({ "taskId": task_id })),
229        )
230    }
231
232    /// Create a `tasks/runWorkflow` request (P2-8).
233    ///
234    /// The server executes the workflow's steps in order and returns the
235    /// per-step results in the response.
236    pub fn run_workflow(id: u64, workflow: &A2AWorkflow) -> Self {
237        let params = serde_json::to_value(workflow)
238            .ok()
239            .map(|v| serde_json::json!({ "workflow": v }));
240        Self::new(id, "tasks/runWorkflow", params)
241    }
242
243    /// Set the request metadata payload (P1-5).
244    pub fn with_metadata(mut self, metadata: Value) -> Self {
245        self.metadata = Some(metadata);
246        self
247    }
248
249    /// Set the W3C-style trace id in metadata (P1-5 / P2-8).
250    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
251        let meta = self.metadata.get_or_insert_with(|| serde_json::json!({}));
252        meta[metadata_keys::TRACE_ID] = serde_json::Value::String(trace_id.into());
253        self
254    }
255
256    /// Set the caller/owner identity in metadata (P1-4).
257    pub fn with_owner(mut self, owner: impl Into<String>) -> Self {
258        let meta = self.metadata.get_or_insert_with(|| serde_json::json!({}));
259        meta[metadata_keys::OWNER] = serde_json::Value::String(owner.into());
260        self
261    }
262
263    /// Set the idempotency key for `tasks/send` (P1-6).
264    pub fn with_message_id(mut self, message_id: impl Into<String>) -> Self {
265        let meta = self.metadata.get_or_insert_with(|| serde_json::json!({}));
266        meta[metadata_keys::MESSAGE_ID] = serde_json::Value::String(message_id.into());
267        self
268    }
269
270    /// W3C-style trace id carried in metadata (P1-5).
271    pub fn trace_id(&self) -> Option<&str> {
272        self.metadata
273            .as_ref()
274            .and_then(|m| m.get(metadata_keys::TRACE_ID))
275            .and_then(serde_json::Value::as_str)
276    }
277
278    /// Caller/owner identity carried in metadata (P1-4).
279    pub fn owner(&self) -> Option<&str> {
280        self.metadata
281            .as_ref()
282            .and_then(|m| m.get(metadata_keys::OWNER))
283            .and_then(serde_json::Value::as_str)
284    }
285
286    /// Idempotency key for `tasks/send` (P1-6).
287    ///
288    /// Checks the metadata `message_id` first, then falls back to a
289    /// top-level `params.messageId` (the A2A wire convention).
290    pub fn message_id(&self) -> Option<&str> {
291        if let Some(id) = self
292            .metadata
293            .as_ref()
294            .and_then(|m| m.get(metadata_keys::MESSAGE_ID))
295            .and_then(serde_json::Value::as_str)
296        {
297            return Some(id);
298        }
299        self.params
300            .as_ref()
301            .and_then(|p| p.get("messageId"))
302            .and_then(serde_json::Value::as_str)
303    }
304
305    /// The `taskId` param for continuation requests (P2-2/P2-3).
306    pub fn task_id(&self) -> Option<&str> {
307        self.params
308            .as_ref()
309            .and_then(|p| p.get("taskId"))
310            .and_then(serde_json::Value::as_str)
311    }
312}
313
314/// A2A JSON-RPC style response.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct A2AResponse {
317    /// JSON-RPC version.
318    pub jsonrpc: String,
319    /// Request identifier this response corresponds to.
320    pub id: u64,
321    /// Result payload (present on success).
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub result: Option<Value>,
324    /// Error payload (present on failure).
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub error: Option<A2AErrorData>,
327}
328
329impl A2AResponse {
330    /// Create a success response.
331    pub fn ok(id: u64, result: Value) -> Self {
332        Self {
333            jsonrpc: "2.0".to_string(),
334            id,
335            result: Some(result),
336            error: None,
337        }
338    }
339
340    /// Create an error response.
341    pub fn error(id: u64, code: i32, message: impl Into<String>) -> Self {
342        Self {
343            jsonrpc: "2.0".to_string(),
344            id,
345            result: None,
346            error: Some(A2AErrorData {
347                code,
348                message: message.into(),
349            }),
350        }
351    }
352
353    /// Create an error response from error data.
354    pub fn from_error_data(id: u64, error: A2AErrorData) -> Self {
355        Self {
356            jsonrpc: "2.0".to_string(),
357            id,
358            result: None,
359            error: Some(error),
360        }
361    }
362
363    /// Whether this response represents an error.
364    pub fn is_error(&self) -> bool {
365        self.error.is_some()
366    }
367
368    /// Extract the result value, or return the error data.
369    pub fn into_result(self) -> Result<Value, A2AErrorData> {
370        if let Some(err) = self.error {
371            return Err(err);
372        }
373        Ok(self.result.unwrap_or(Value::Null))
374    }
375}
376
377/// Error payload within an A2A JSON-RPC response.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct A2AErrorData {
380    /// Error code.
381    pub code: i32,
382    /// Human-readable error message.
383    pub message: String,
384}
385
386impl A2AErrorData {
387    /// Create new error data.
388    pub fn new(code: i32, message: impl Into<String>) -> Self {
389        Self {
390            code,
391            message: message.into(),
392        }
393    }
394
395    /// Standard error: method not found.
396    pub fn method_not_found() -> Self {
397        Self::new(-32601, "Method not found")
398    }
399
400    /// Standard error: invalid params.
401    pub fn invalid_params(msg: impl Into<String>) -> Self {
402        Self::new(-32602, msg)
403    }
404
405    /// Standard error: internal error.
406    pub fn internal_error(msg: impl Into<String>) -> Self {
407        Self::new(-32603, msg)
408    }
409}
410
411impl std::fmt::Display for A2AErrorData {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        write!(f, "A2A Error [{}]: {}", self.code, self.message)
414    }
415}
416
417impl std::error::Error for A2AErrorData {}
418
419/// A push notification emitted by a streaming A2A server (P2-1).
420///
421/// Sent over an SSE connection as `data: <json>` lines, discriminated by the
422/// `kind` field. Mirrors the A2A `TaskStatusUpdateEvent` /
423/// `TaskArtifactUpdateEvent` pair.
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(tag = "kind", rename_all = "kebab-case")]
426pub enum TaskPushNotification {
427    /// A task status transition (e.g. `submitted` → `working`, or a terminal
428    /// state). Carries an optional error message when the status is `failed`.
429    #[serde(rename_all = "camelCase")]
430    StatusUpdate {
431        /// Task id.
432        id: String,
433        /// New status.
434        status: TaskStatus,
435        /// Optional error message (present when status is `failed`).
436        #[serde(skip_serializing_if = "Option::is_none")]
437        error: Option<String>,
438    },
439    /// A chunk of partial output while the task is still `working`.
440    #[serde(rename_all = "camelCase")]
441    ArtifactUpdate {
442        /// Task id.
443        id: String,
444        /// Partial (or final) output.
445        artifact: A2ATaskResult,
446    },
447}
448
449impl TaskPushNotification {
450    /// Create a status-update notification.
451    pub fn status(id: impl Into<String>, status: TaskStatus) -> Self {
452        TaskPushNotification::StatusUpdate {
453            id: id.into(),
454            status,
455            error: None,
456        }
457    }
458
459    /// Create a status-update notification carrying an error message.
460    pub fn status_with_error(
461        id: impl Into<String>,
462        status: TaskStatus,
463        error: impl Into<String>,
464    ) -> Self {
465        TaskPushNotification::StatusUpdate {
466            id: id.into(),
467            status,
468            error: Some(error.into()),
469        }
470    }
471
472    /// Create an artifact-update notification.
473    pub fn artifact(id: impl Into<String>, artifact: A2ATaskResult) -> Self {
474        TaskPushNotification::ArtifactUpdate {
475            id: id.into(),
476            artifact,
477        }
478    }
479
480    /// The task id this notification refers to.
481    pub fn id(&self) -> &str {
482        match self {
483            TaskPushNotification::StatusUpdate { id, .. }
484            | TaskPushNotification::ArtifactUpdate { id, .. } => id,
485        }
486    }
487
488    /// The current status, if this is a status-update notification.
489    pub fn status_value(&self) -> Option<TaskStatus> {
490        match self {
491            TaskPushNotification::StatusUpdate { status, .. } => Some(*status),
492            TaskPushNotification::ArtifactUpdate { .. } => None,
493        }
494    }
495}
496
497/// W3C Trace Context (`traceparent` header) (P2-8).
498///
499/// Format: `version-trace_id-parent_id-flags`, e.g.
500/// `00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01`.
501/// `trace_id` is 32 lowercase hex chars, `parent_id` 16, flags 2 (bit 0 =
502/// sampled).
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504pub struct TraceContext {
505    /// Version byte (currently `0`, serialized as `"00"`).
506    pub version: u8,
507    /// 32-char lowercase hex trace id.
508    pub trace_id: String,
509    /// 16-char lowercase hex parent span id.
510    pub parent_id: String,
511    /// 2-char hex flags byte (bit 0 = sampled).
512    pub flags: u8,
513}
514
515impl TraceContext {
516    /// Create a new (unsampled) trace context with version `00`.
517    pub fn new(trace_id: impl Into<String>, parent_id: impl Into<String>) -> Self {
518        Self {
519            version: 0,
520            trace_id: trace_id.into(),
521            parent_id: parent_id.into(),
522            flags: 0,
523        }
524    }
525
526    /// Mark the trace as sampled (sets flags bit 0).
527    pub fn sampled(mut self) -> Self {
528        self.flags |= 0b0000_0001;
529        self
530    }
531
532    /// Whether this trace has been marked as sampled.
533    pub fn is_sampled(&self) -> bool {
534        self.flags & 0b0000_0001 != 0
535    }
536
537    /// Parse a `traceparent` header value.
538    pub fn parse(s: &str) -> Option<Self> {
539        let mut parts = s.trim().split('-');
540        let version = parts.next()?;
541        let trace_id = parts.next()?;
542        let parent_id = parts.next()?;
543        let flags = parts.next()?;
544        if parts.next().is_some() {
545            return None;
546        }
547        let version = u8::from_str_radix(version, 16).ok()?;
548        if trace_id.len() != 32 || !trace_id.bytes().all(|b| b.is_ascii_hexdigit()) {
549            return None;
550        }
551        if parent_id.len() != 16 || !parent_id.bytes().all(|b| b.is_ascii_hexdigit()) {
552            return None;
553        }
554        let flags = u8::from_str_radix(flags, 16).ok()?;
555        Some(Self {
556            version,
557            trace_id: trace_id.to_string(),
558            parent_id: parent_id.to_string(),
559            flags,
560        })
561    }
562
563    /// Serialize to a `traceparent` header value.
564    pub fn to_traceparent(&self) -> String {
565        format!(
566            "{:02x}-{}-{}-{:02x}",
567            self.version, self.trace_id, self.parent_id, self.flags
568        )
569    }
570}
571
572/// Transport-neutral message envelope shared across HTTP and gRPC (P2-8).
573///
574/// A2A messages carry identical semantics over JSON-RPC/HTTP and (future)
575/// gRPC; this envelope is the common representation so a message produced on
576/// one transport can be handed to the other without reshaping. It bundles the
577/// message with the cross-cutting metadata that would otherwise live in an
578/// HTTP header or a gRPC field: W3C trace context, caller identity, and
579/// arbitrary application headers.
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct MessageEnvelope {
582    /// Protocol version this envelope conforms to.
583    pub protocol_version: String,
584    /// The message payload.
585    pub message: A2AMessage,
586    /// W3C trace context carried on the message (P2-8).
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub trace: Option<TraceContext>,
589    /// Caller / organization identity (P1-4).
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub owner: Option<String>,
592    /// Arbitrary application-defined headers.
593    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
594    pub headers: HashMap<String, String>,
595}
596
597impl MessageEnvelope {
598    /// Wrap a message in a fresh envelope for the current protocol version.
599    pub fn new(message: A2AMessage) -> Self {
600        Self {
601            protocol_version: "0.3.0".to_string(),
602            message,
603            trace: None,
604            owner: None,
605            headers: HashMap::new(),
606        }
607    }
608
609    /// Attach the W3C trace context (P2-8).
610    pub fn with_trace(mut self, trace: TraceContext) -> Self {
611        self.trace = Some(trace);
612        self
613    }
614
615    /// Attach the caller / organization identity (P1-4).
616    pub fn with_owner(mut self, owner: impl Into<String>) -> Self {
617        self.owner = Some(owner.into());
618        self
619    }
620
621    /// Attach an application-defined header.
622    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
623        self.headers.insert(key.into(), value.into());
624        self
625    }
626
627    /// Unwrap into the bare message.
628    pub fn into_message(self) -> A2AMessage {
629        self.message
630    }
631}
632
633/// Filter for listing tasks from a task store (P1-1).
634///
635/// Used by `A2AServer::handle_tasks_list` and the store's `list` method to
636/// scope results by owner and/or status.
637#[derive(Debug, Clone, Default)]
638pub struct TaskFilter {
639    /// Only include tasks owned by this caller (None = no owner filter).
640    pub owner: Option<String>,
641    /// Only include tasks in these statuses (None = all statuses).
642    pub statuses: Option<Vec<TaskStatus>>,
643}
644
645impl TaskFilter {
646    /// Create an empty filter (matches all tasks).
647    pub fn new() -> Self {
648        Self::default()
649    }
650
651    /// Restrict to tasks owned by `owner`.
652    pub fn with_owner(mut self, owner: impl Into<String>) -> Self {
653        self.owner = Some(owner.into());
654        self
655    }
656
657    /// Restrict to tasks in the given statuses.
658    pub fn with_statuses(mut self, statuses: Vec<TaskStatus>) -> Self {
659        self.statuses = Some(statuses);
660        self
661    }
662
663    /// Whether a task matches this filter.
664    pub fn matches(&self, task: &A2ATask) -> bool {
665        if let Some(owner) = &self.owner {
666            if task.owner.as_deref() != Some(owner.as_str()) {
667                return false;
668            }
669        }
670        if let Some(statuses) = &self.statuses {
671            if !statuses.contains(&task.status) {
672                return false;
673            }
674        }
675        true
676    }
677}