Skip to main content

a2a_rs/adapter/transport/
connectrpc.rs

1//! The ConnectRPC transport adapter.
2//!
3//! `ConnectRpcAdapter` is the **outer** half of the service/transport split: a
4//! thin transport adapter that implements the generated [`A2aService`] surface.
5//! Its only job is to decode `buffa` wire views into domain values, delegate to
6//! the inner [`TaskService`], and re-encode the domain results (and map
7//! [`A2AError`] onto ConnectRPC error codes). All use-case orchestration lives
8//! in [`TaskService`]; this layer holds no port traits directly.
9//!
10//! The public constructors (`new`, `with_handler`, `with_streaming_handler`)
11//! each build the inner service and wrap it.
12
13use async_trait::async_trait;
14use buffa::Enumeration;
15use std::pin::Pin;
16
17use crate::{
18    application::{SendOptions, TaskService},
19    domain::{
20        A2AError, AgentCard, SendCompletion, Task, TaskArtifactUpdateEvent, TaskId,
21        TaskPushNotificationConfig, TaskStatusUpdateEvent,
22        generated::{
23            A2aService, CancelTaskRequestView, DeleteTaskPushNotificationConfigRequestView,
24            GetExtendedAgentCardRequestView, GetTaskPushNotificationConfigRequestView,
25            GetTaskRequestView, ListTaskPushNotificationConfigsRequestView,
26            ListTaskPushNotificationConfigsResponse, ListTasksRequest, ListTasksRequestView,
27            ListTasksResponse, SendMessageRequestView, SendMessageResponse, StreamResponse,
28            SubscribeToTaskRequestView, TaskArtifactUpdateEvent as GenTaskArtifactUpdateEvent,
29            TaskPushNotificationConfigView, TaskState,
30            TaskStatusUpdateEvent as GenTaskStatusUpdateEvent, send_message_response,
31            stream_response,
32        },
33    },
34    port::{
35        AsyncMessageHandler, AsyncNotificationManager, AsyncStreamingHandler, AsyncTaskLifecycle,
36        AsyncTaskQuery, SeqEvent, UpdateEvent, streaming_handler::Subscriber,
37    },
38    services::server::AgentInfoProvider,
39};
40
41/// ConnectRPC transport adapter over a [`TaskService`].
42///
43/// Holds no ports directly — it owns the inner application service and forwards
44/// decoded requests to it. Dispatch into the service goes through the service's
45/// `Arc<dyn …>` fields, which is a cold path against the I/O each call performs.
46#[derive(Clone)]
47pub struct ConnectRpcAdapter {
48    service: TaskService,
49}
50
51impl ConnectRpcAdapter {
52    /// Create a new adapter from separate handlers, defaulting to a no-op
53    /// streaming handler.
54    ///
55    /// `tasks` supplies both the lifecycle and query capabilities.
56    pub fn new(
57        message_handler: impl AsyncMessageHandler + 'static,
58        tasks: impl AsyncTaskLifecycle + AsyncTaskQuery + 'static,
59        notification_manager: impl AsyncNotificationManager + 'static,
60        agent_info: impl AgentInfoProvider + 'static,
61    ) -> Self {
62        Self {
63            service: TaskService::new(
64                message_handler,
65                tasks,
66                notification_manager,
67                agent_info,
68                NoopStreamingHandler,
69                crate::port::NoopPushNotifier,
70            ),
71        }
72    }
73
74    /// Create a new adapter from a single handler that implements every port,
75    /// defaulting to a no-op streaming handler.
76    pub fn with_handler(
77        handler: impl AsyncMessageHandler
78        + AsyncTaskLifecycle
79        + AsyncTaskQuery
80        + AsyncNotificationManager
81        + 'static,
82        agent_info: impl AgentInfoProvider + 'static,
83    ) -> Self {
84        Self {
85            service: TaskService::with_handler(
86                handler,
87                agent_info,
88                NoopStreamingHandler,
89                crate::port::NoopPushNotifier,
90            ),
91        }
92    }
93
94    /// Builder-style method to inject custom streaming handler support.
95    pub fn with_streaming_handler(
96        self,
97        streaming_handler: impl AsyncStreamingHandler + 'static,
98    ) -> Self {
99        Self {
100            service: self.service.with_streaming_handler(streaming_handler),
101        }
102    }
103
104    /// Builder-style method to inject a custom push notifier.
105    pub fn with_push_notifier(
106        self,
107        push_notifier: impl crate::port::AsyncPushNotifier + 'static,
108    ) -> Self {
109        Self {
110            service: self.service.with_push_notifier(push_notifier),
111        }
112    }
113}
114
115/// Helper function to map A2AError to connectrpc::ConnectError
116fn map_err(e: A2AError) -> ::connectrpc::ConnectError {
117    match e {
118        A2AError::TaskNotFound(msg) => {
119            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::NotFound, msg)
120        }
121        A2AError::InvalidParams(msg) => {
122            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::InvalidArgument, msg)
123        }
124        A2AError::ValidationError { field, message } => ::connectrpc::ConnectError::new(
125            ::connectrpc::ErrorCode::InvalidArgument,
126            format!("{}: {}", field, message),
127        ),
128        A2AError::UnsupportedOperation(msg) => {
129            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Unimplemented, msg)
130        }
131        A2AError::AuthenticatedExtendedCardNotConfigured => ::connectrpc::ConnectError::new(
132            ::connectrpc::ErrorCode::FailedPrecondition,
133            "Authenticated extended card not configured".to_string(),
134        ),
135        A2AError::MethodNotFound(msg) => {
136            ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Unimplemented, msg)
137        }
138        _ => ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Internal, e.to_string()),
139    }
140}
141
142/// Helper to map domain metadata to protobuf Struct
143fn map_metadata(
144    opt: Option<serde_json::Map<String, serde_json::Value>>,
145) -> ::buffa::MessageField<::buffa_types::google::protobuf::Struct> {
146    if let Some(map) = opt {
147        let val = serde_json::Value::Object(map);
148        if let Ok(struc) = serde_json::from_value::<::buffa_types::google::protobuf::Struct>(val) {
149            return ::buffa::MessageField::some(struc);
150        }
151    }
152    ::buffa::MessageField::none()
153}
154
155fn map_status_update(
156    evt: crate::domain::events::TaskStatusUpdateEvent,
157) -> GenTaskStatusUpdateEvent {
158    GenTaskStatusUpdateEvent {
159        task_id: evt.task_id,
160        context_id: evt.context_id,
161        status: ::buffa::MessageField::some(evt.status),
162        metadata: map_metadata(evt.metadata),
163        ..Default::default()
164    }
165}
166
167fn map_artifact_update(
168    evt: crate::domain::events::TaskArtifactUpdateEvent,
169) -> GenTaskArtifactUpdateEvent {
170    GenTaskArtifactUpdateEvent {
171        task_id: evt.task_id,
172        context_id: evt.context_id,
173        artifact: ::buffa::MessageField::some(evt.artifact),
174        append: evt.append.unwrap_or(false),
175        last_chunk: evt.last_chunk.unwrap_or(false),
176        metadata: map_metadata(evt.metadata),
177        ..Default::default()
178    }
179}
180
181/// Map a domain [`UpdateEvent`] onto its wire [`StreamResponse`].
182///
183/// Shared with the JSON-RPC adapter so both transports map streaming updates
184/// through one path.
185pub(super) fn map_update_event(evt: UpdateEvent) -> StreamResponse {
186    match evt {
187        UpdateEvent::StatusUpdate(event) => StreamResponse {
188            payload: Some(stream_response::Payload::StatusUpdate(Box::new(
189                map_status_update(event),
190            ))),
191            ..Default::default()
192        },
193        UpdateEvent::ArtifactUpdate(event) => StreamResponse {
194            payload: Some(stream_response::Payload::ArtifactUpdate(Box::new(
195                map_artifact_update(event),
196            ))),
197            ..Default::default()
198        },
199    }
200}
201
202impl A2aService for ConnectRpcAdapter {
203    async fn send_message(
204        &self,
205        ctx: ::connectrpc::Context,
206        request: ::buffa::view::OwnedView<SendMessageRequestView<'static>>,
207    ) -> Result<(SendMessageResponse, ::connectrpc::Context), ::connectrpc::ConnectError> {
208        let req = request.to_owned_message();
209        let message = req.message.into_option().ok_or_else(|| {
210            ::connectrpc::ConnectError::new(
211                ::connectrpc::ErrorCode::InvalidArgument,
212                "Missing message".to_string(),
213            )
214        })?;
215        let config = req.configuration.into_option();
216
217        let task_id = message.task_id.clone();
218        let session_id = if message.context_id.is_empty() {
219            None
220        } else {
221            Some(message.context_id.as_str())
222        };
223
224        let task = self
225            .service
226            .send_message(&task_id, &message, session_id, decode_send_config(config))
227            .await
228            .map_err(map_err)?;
229
230        let response = SendMessageResponse {
231            payload: Some(send_message_response::Payload::Task(Box::new(task))),
232            ..Default::default()
233        };
234
235        Ok((response, ctx))
236    }
237
238    #[allow(clippy::result_large_err)]
239    async fn send_streaming_message(
240        &self,
241        ctx: ::connectrpc::Context,
242        request: ::buffa::view::OwnedView<SendMessageRequestView<'static>>,
243    ) -> Result<
244        (
245            ::std::pin::Pin<
246                Box<
247                    dyn ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>>
248                        + Send,
249                >,
250            >,
251            ::connectrpc::Context,
252        ),
253        ::connectrpc::ConnectError,
254    > {
255        let req = request.to_owned_message();
256        let message = req.message.into_option().ok_or_else(|| {
257            ::connectrpc::ConnectError::new(
258                ::connectrpc::ErrorCode::InvalidArgument,
259                "Missing message".to_string(),
260            )
261        })?;
262        let config = req.configuration.into_option();
263
264        let task_id = message.task_id.clone();
265        let session_id = if message.context_id.is_empty() {
266            None
267        } else {
268            Some(message.context_id.as_str())
269        };
270
271        // `completion` is deliberately dropped here rather than passed along:
272        // on a streaming call the stream itself is the wait, so blocking the
273        // initial response would only delay the snapshot the client needs to
274        // start reading.
275        let opts = decode_send_config(config);
276
277        let (task, update_stream) = self
278            .service
279            .send_streaming_message(
280                &task_id,
281                &message,
282                session_id,
283                opts.push_config,
284                opts.history_limit,
285            )
286            .await
287            .map_err(map_err)?;
288
289        use futures::StreamExt;
290
291        let initial_response = StreamResponse {
292            payload: Some(stream_response::Payload::Task(Box::new(task))),
293            ..Default::default()
294        };
295
296        let mapped_stream =
297            update_stream.map(|item| item.map(|seq| map_update_event(seq.event)).map_err(map_err));
298
299        let chained_stream =
300            futures::stream::once(async { Ok(initial_response) }).chain(mapped_stream);
301
302        Ok((Box::pin(chained_stream), ctx))
303    }
304
305    async fn get_task(
306        &self,
307        ctx: ::connectrpc::Context,
308        request: ::buffa::view::OwnedView<GetTaskRequestView<'static>>,
309    ) -> Result<(Task, ::connectrpc::Context), ::connectrpc::ConnectError> {
310        let req = request.to_owned_message();
311        let history_length = req.history_length.map(|l| l as u32);
312        let id: TaskId = req.id.parse().map_err(map_err)?;
313        let task = self
314            .service
315            .get(&id, history_length)
316            .await
317            .map_err(map_err)?;
318        Ok((task, ctx))
319    }
320
321    async fn list_tasks(
322        &self,
323        ctx: ::connectrpc::Context,
324        request: ::buffa::view::OwnedView<ListTasksRequestView<'static>>,
325    ) -> Result<(ListTasksResponse, ::connectrpc::Context), ::connectrpc::ConnectError> {
326        let req = request.to_owned_message();
327        let params = list_request_to_params(req);
328
329        let result = self.service.list(&params).await.map_err(map_err)?;
330
331        let response = ListTasksResponse {
332            tasks: result.tasks,
333            next_page_token: result.next_page_token,
334            page_size: result.page_size,
335            total_size: result.total_size,
336            ..Default::default()
337        };
338
339        Ok((response, ctx))
340    }
341
342    async fn cancel_task(
343        &self,
344        ctx: ::connectrpc::Context,
345        request: ::buffa::view::OwnedView<CancelTaskRequestView<'static>>,
346    ) -> Result<(Task, ::connectrpc::Context), ::connectrpc::ConnectError> {
347        let req = request.to_owned_message();
348        let id: TaskId = req.id.parse().map_err(map_err)?;
349        let task = self.service.cancel(&id).await.map_err(map_err)?;
350        Ok((task, ctx))
351    }
352
353    #[allow(clippy::result_large_err)]
354    async fn subscribe_to_task(
355        &self,
356        ctx: ::connectrpc::Context,
357        request: ::buffa::view::OwnedView<SubscribeToTaskRequestView<'static>>,
358    ) -> Result<
359        (
360            ::std::pin::Pin<
361                Box<
362                    dyn ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>>
363                        + Send,
364                >,
365            >,
366            ::connectrpc::Context,
367        ),
368        ::connectrpc::ConnectError,
369    > {
370        let req = request.to_owned_message();
371
372        let (initial_task, update_stream) = self
373            .service
374            .subscribe(&req.id, None)
375            .await
376            .map_err(map_err)?;
377
378        use futures::StreamExt;
379
380        let mapped_stream =
381            update_stream.map(|item| item.map(|seq| map_update_event(seq.event)).map_err(map_err));
382
383        if let Some(task) = initial_task {
384            let initial_response = StreamResponse {
385                payload: Some(stream_response::Payload::Task(Box::new(task))),
386                ..Default::default()
387            };
388            let chained_stream =
389                futures::stream::once(async { Ok(initial_response) }).chain(mapped_stream);
390            Ok((Box::pin(chained_stream), ctx))
391        } else {
392            Ok((Box::pin(mapped_stream), ctx))
393        }
394    }
395
396    async fn create_task_push_notification_config(
397        &self,
398        ctx: ::connectrpc::Context,
399        request: ::buffa::view::OwnedView<TaskPushNotificationConfigView<'static>>,
400    ) -> Result<(TaskPushNotificationConfig, ::connectrpc::Context), ::connectrpc::ConnectError>
401    {
402        let config = request.to_owned_message();
403        let created_config = self
404            .service
405            .set_push_config(&config)
406            .await
407            .map_err(map_err)?;
408        Ok((created_config, ctx))
409    }
410
411    async fn get_task_push_notification_config(
412        &self,
413        ctx: ::connectrpc::Context,
414        request: ::buffa::view::OwnedView<GetTaskPushNotificationConfigRequestView<'static>>,
415    ) -> Result<(TaskPushNotificationConfig, ::connectrpc::Context), ::connectrpc::ConnectError>
416    {
417        let req = request.to_owned_message();
418        let params = crate::domain::GetTaskPushNotificationConfigParams {
419            id: req.task_id,
420            push_notification_config_id: Some(req.id),
421            metadata: None,
422        };
423        let config = self
424            .service
425            .get_push_config(&params)
426            .await
427            .map_err(map_err)?;
428        Ok((config, ctx))
429    }
430
431    async fn list_task_push_notification_configs(
432        &self,
433        ctx: ::connectrpc::Context,
434        request: ::buffa::view::OwnedView<ListTaskPushNotificationConfigsRequestView<'static>>,
435    ) -> Result<
436        (
437            ListTaskPushNotificationConfigsResponse,
438            ::connectrpc::Context,
439        ),
440        ::connectrpc::ConnectError,
441    > {
442        let req = request.to_owned_message();
443        let params = crate::domain::ListTaskPushNotificationConfigsParams {
444            id: req.task_id,
445            metadata: None,
446        };
447        let configs = self
448            .service
449            .list_push_configs(&params)
450            .await
451            .map_err(map_err)?;
452        let response = ListTaskPushNotificationConfigsResponse {
453            configs,
454            ..Default::default()
455        };
456        Ok((response, ctx))
457    }
458
459    async fn get_extended_agent_card(
460        &self,
461        ctx: ::connectrpc::Context,
462        request: ::buffa::view::OwnedView<GetExtendedAgentCardRequestView<'static>>,
463    ) -> Result<(AgentCard, ::connectrpc::Context), ::connectrpc::ConnectError> {
464        let _req = request.to_owned_message();
465        let card = self.service.extended_agent_card().await.map_err(map_err)?;
466        Ok((card, ctx))
467    }
468
469    async fn delete_task_push_notification_config(
470        &self,
471        ctx: ::connectrpc::Context,
472        request: ::buffa::view::OwnedView<DeleteTaskPushNotificationConfigRequestView<'static>>,
473    ) -> Result<
474        (
475            ::buffa_types::google::protobuf::Empty,
476            ::connectrpc::Context,
477        ),
478        ::connectrpc::ConnectError,
479    > {
480        let req = request.to_owned_message();
481        let params = crate::domain::DeleteTaskPushNotificationConfigParams {
482            id: req.task_id,
483            push_notification_config_id: req.id,
484            metadata: None,
485        };
486        self.service
487            .delete_push_config(&params)
488            .await
489            .map_err(map_err)?;
490        Ok((::buffa_types::google::protobuf::Empty::default(), ctx))
491    }
492}
493
494/// Map a generated `ListTasksRequest` (proto wire message) onto the domain
495/// [`ListTasksParams`]. Shared with the JSON-RPC adapter.
496pub(super) fn list_request_to_params(req: ListTasksRequest) -> crate::domain::ListTasksParams {
497    crate::domain::ListTasksParams {
498        context_id: if req.context_id.is_empty() {
499            None
500        } else {
501            Some(req.context_id)
502        },
503        status: match req.status.to_i32() {
504            0 => None,
505            val => Some(TaskState::from_i32(val).unwrap_or(TaskState::TASK_STATE_UNSPECIFIED)),
506        },
507        page_size: req.page_size,
508        page_token: if req.page_token.is_empty() {
509            None
510        } else {
511            Some(req.page_token)
512        },
513        history_length: req.history_length,
514        include_artifacts: req.include_artifacts,
515        status_timestamp_after: req.status_timestamp_after.as_option().map(|t| {
516            let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(t.seconds, t.nanos as u32)
517                .unwrap_or_default();
518            dt.to_rfc3339()
519        }),
520        metadata: None,
521    }
522}
523
524/// Decode the optional `SendMessageConfiguration` view into the [`SendOptions`]
525/// the service expects.
526///
527/// Shared with the JSON-RPC adapter (both decode the same generated config
528/// message), so the two transports agree on the wire shape.
529///
530/// Note the polarity: an **absent** configuration is not "no opinion", it is
531/// `return_immediately = false`, which obliges the server to wait. That is the
532/// proto3 default and the case every conformant client hits by default, so it
533/// has to be the branch that waits — [`SendOptions::default`] gives exactly
534/// that.
535pub(super) fn decode_send_config(
536    config: Option<crate::domain::generated::SendMessageConfiguration>,
537) -> SendOptions {
538    let Some(c) = config else {
539        return SendOptions::default();
540    };
541    SendOptions {
542        push_config: c.task_push_notification_config.into_option(),
543        history_limit: c.history_length.map(|limit| limit as u32),
544        completion: if c.return_immediately {
545            SendCompletion::WhenCreated
546        } else {
547            SendCompletion::WhenSettled
548        },
549    }
550}
551
552/// A no-op [`AsyncStreamingHandler`] used as the adapter's default streaming port
553/// when the caller has no real streaming backend to inject.
554#[derive(Clone, Debug, Default)]
555pub struct NoopStreamingHandler;
556
557#[async_trait]
558impl AsyncStreamingHandler for NoopStreamingHandler {
559    async fn add_status_subscriber(
560        &self,
561        _task_id: &str,
562        _subscriber: Box<dyn Subscriber<TaskStatusUpdateEvent> + Send + Sync>,
563    ) -> Result<String, A2AError> {
564        Err(A2AError::UnsupportedOperation(
565            "Streaming not supported by this processor".to_string(),
566        ))
567    }
568
569    async fn add_artifact_subscriber(
570        &self,
571        _task_id: &str,
572        _subscriber: Box<dyn Subscriber<TaskArtifactUpdateEvent> + Send + Sync>,
573    ) -> Result<String, A2AError> {
574        Err(A2AError::UnsupportedOperation(
575            "Streaming not supported by this processor".to_string(),
576        ))
577    }
578
579    async fn remove_subscription(&self, _subscription_id: &str) -> Result<(), A2AError> {
580        Ok(())
581    }
582
583    async fn remove_task_subscribers(&self, _task_id: &str) -> Result<(), A2AError> {
584        Ok(())
585    }
586
587    async fn get_subscriber_count(&self, _task_id: &str) -> Result<usize, A2AError> {
588        Ok(0)
589    }
590
591    async fn broadcast_status_update(
592        &self,
593        _task_id: &str,
594        _update: TaskStatusUpdateEvent,
595    ) -> Result<(), A2AError> {
596        Ok(())
597    }
598
599    async fn broadcast_artifact_update(
600        &self,
601        _task_id: &str,
602        _update: TaskArtifactUpdateEvent,
603    ) -> Result<(), A2AError> {
604        Ok(())
605    }
606
607    async fn status_update_stream(
608        &self,
609        _task_id: &str,
610    ) -> Result<
611        Pin<Box<dyn ::futures::Stream<Item = Result<TaskStatusUpdateEvent, A2AError>> + Send>>,
612        A2AError,
613    > {
614        Err(A2AError::UnsupportedOperation(
615            "Streaming not supported by this processor".to_string(),
616        ))
617    }
618
619    async fn artifact_update_stream(
620        &self,
621        _task_id: &str,
622    ) -> Result<
623        Pin<Box<dyn ::futures::Stream<Item = Result<TaskArtifactUpdateEvent, A2AError>> + Send>>,
624        A2AError,
625    > {
626        Err(A2AError::UnsupportedOperation(
627            "Streaming not supported by this processor".to_string(),
628        ))
629    }
630
631    async fn combined_update_stream(
632        &self,
633        _task_id: &str,
634        _from_event_id: Option<u64>,
635    ) -> Result<Pin<Box<dyn ::futures::Stream<Item = Result<SeqEvent, A2AError>> + Send>>, A2AError>
636    {
637        Err(A2AError::UnsupportedOperation(
638            "Streaming not supported by this processor".to_string(),
639        ))
640    }
641}