1use 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, AuthPrincipal, RequestContext, SeqEvent, UpdateEvent,
37 streaming_handler::Subscriber,
38 },
39 services::server::AgentInfoProvider,
40};
41
42use super::resume;
43
44#[allow(clippy::result_large_err)]
52fn wire_stream(
53 stream: crate::application::UpdateStream,
54 stamp_ids: bool,
55) -> impl ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>> + Send {
56 use futures::StreamExt;
57
58 stream.map(move |item| {
59 item.map(|seq| {
60 let mut event = seq.event;
61 if stamp_ids {
62 resume::stamp_event_id(&mut event, seq.id);
63 }
64 map_update_event(event)
65 })
66 .map_err(map_err)
67 })
68}
69
70#[derive(Clone)]
76pub struct ConnectRpcAdapter {
77 service: TaskService,
78}
79
80impl ConnectRpcAdapter {
81 pub fn new(
86 message_handler: impl AsyncMessageHandler + 'static,
87 tasks: impl AsyncTaskLifecycle + AsyncTaskQuery + 'static,
88 notification_manager: impl AsyncNotificationManager + 'static,
89 agent_info: impl AgentInfoProvider + 'static,
90 ) -> Self {
91 Self {
92 service: TaskService::new(
93 message_handler,
94 tasks,
95 notification_manager,
96 agent_info,
97 NoopStreamingHandler,
98 crate::port::NoopPushNotifier,
99 ),
100 }
101 }
102
103 pub fn with_handler(
106 handler: impl AsyncMessageHandler
107 + AsyncTaskLifecycle
108 + AsyncTaskQuery
109 + AsyncNotificationManager
110 + 'static,
111 agent_info: impl AgentInfoProvider + 'static,
112 ) -> Self {
113 Self {
114 service: TaskService::with_handler(
115 handler,
116 agent_info,
117 NoopStreamingHandler,
118 crate::port::NoopPushNotifier,
119 ),
120 }
121 }
122
123 pub fn with_streaming_handler(
125 self,
126 streaming_handler: impl AsyncStreamingHandler + 'static,
127 ) -> Self {
128 Self {
129 service: self.service.with_streaming_handler(streaming_handler),
130 }
131 }
132
133 pub fn with_push_notifier(
135 self,
136 push_notifier: impl crate::port::AsyncPushNotifier + 'static,
137 ) -> Self {
138 Self {
139 service: self.service.with_push_notifier(push_notifier),
140 }
141 }
142}
143
144fn request_context(ctx: &::connectrpc::Context, context_id: &str) -> RequestContext {
152 RequestContext::anonymous()
153 .with_session(context_id)
154 .with_principal(ctx.extensions.get::<AuthPrincipal>().cloned())
155}
156
157fn map_err(e: A2AError) -> ::connectrpc::ConnectError {
159 match e {
160 A2AError::TaskNotFound(msg) => {
161 ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::NotFound, msg)
162 }
163 A2AError::InvalidParams(msg) => {
164 ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::InvalidArgument, msg)
165 }
166 A2AError::ValidationError { field, message } => ::connectrpc::ConnectError::new(
167 ::connectrpc::ErrorCode::InvalidArgument,
168 format!("{}: {}", field, message),
169 ),
170 A2AError::UnsupportedOperation(msg) => {
171 ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Unimplemented, msg)
172 }
173 A2AError::AuthenticatedExtendedCardNotConfigured => ::connectrpc::ConnectError::new(
174 ::connectrpc::ErrorCode::FailedPrecondition,
175 "Authenticated extended card not configured".to_string(),
176 ),
177 A2AError::MethodNotFound(msg) => {
178 ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Unimplemented, msg)
179 }
180 A2AError::ContextAccessDenied { context_id } => ::connectrpc::ConnectError::new(
184 ::connectrpc::ErrorCode::PermissionDenied,
185 format!("context {context_id} belongs to another principal"),
186 ),
187 _ => ::connectrpc::ConnectError::new(::connectrpc::ErrorCode::Internal, e.to_string()),
188 }
189}
190
191fn map_metadata(
193 opt: Option<serde_json::Map<String, serde_json::Value>>,
194) -> ::buffa::MessageField<::buffa_types::google::protobuf::Struct> {
195 if let Some(map) = opt {
196 let val = serde_json::Value::Object(map);
197 if let Ok(struc) = serde_json::from_value::<::buffa_types::google::protobuf::Struct>(val) {
198 return ::buffa::MessageField::some(struc);
199 }
200 }
201 ::buffa::MessageField::none()
202}
203
204fn map_status_update(
205 evt: crate::domain::events::TaskStatusUpdateEvent,
206) -> GenTaskStatusUpdateEvent {
207 GenTaskStatusUpdateEvent {
208 task_id: evt.task_id,
209 context_id: evt.context_id,
210 status: ::buffa::MessageField::some(evt.status),
211 metadata: map_metadata(evt.metadata),
212 ..Default::default()
213 }
214}
215
216fn map_artifact_update(
217 evt: crate::domain::events::TaskArtifactUpdateEvent,
218) -> GenTaskArtifactUpdateEvent {
219 GenTaskArtifactUpdateEvent {
220 task_id: evt.task_id,
221 context_id: evt.context_id,
222 artifact: ::buffa::MessageField::some(evt.artifact),
223 append: evt.append.unwrap_or(false),
224 last_chunk: evt.last_chunk.unwrap_or(false),
225 metadata: map_metadata(evt.metadata),
226 ..Default::default()
227 }
228}
229
230pub(super) fn map_update_event(evt: UpdateEvent) -> StreamResponse {
235 match evt {
236 UpdateEvent::StatusUpdate(event) => StreamResponse {
237 payload: Some(stream_response::Payload::StatusUpdate(Box::new(
238 map_status_update(event),
239 ))),
240 ..Default::default()
241 },
242 UpdateEvent::ArtifactUpdate(event) => StreamResponse {
243 payload: Some(stream_response::Payload::ArtifactUpdate(Box::new(
244 map_artifact_update(event),
245 ))),
246 ..Default::default()
247 },
248 }
249}
250
251impl A2aService for ConnectRpcAdapter {
252 async fn send_message(
253 &self,
254 ctx: ::connectrpc::Context,
255 request: ::buffa::view::OwnedView<SendMessageRequestView<'static>>,
256 ) -> Result<(SendMessageResponse, ::connectrpc::Context), ::connectrpc::ConnectError> {
257 let req = request.to_owned_message();
258 let message = req.message.into_option().ok_or_else(|| {
259 ::connectrpc::ConnectError::new(
260 ::connectrpc::ErrorCode::InvalidArgument,
261 "Missing message".to_string(),
262 )
263 })?;
264 let config = req.configuration.into_option();
265
266 let request_ctx = request_context(&ctx, &message.context_id);
267
268 let task = self
269 .service
270 .send_message(message, &request_ctx, decode_send_config(config))
271 .await
272 .map_err(map_err)?;
273
274 let response = SendMessageResponse {
275 payload: Some(send_message_response::Payload::Task(Box::new(task))),
276 ..Default::default()
277 };
278
279 Ok((response, ctx))
280 }
281
282 #[allow(clippy::result_large_err)]
283 async fn send_streaming_message(
284 &self,
285 ctx: ::connectrpc::Context,
286 request: ::buffa::view::OwnedView<SendMessageRequestView<'static>>,
287 ) -> Result<
288 (
289 ::std::pin::Pin<
290 Box<
291 dyn ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>>
292 + Send,
293 >,
294 >,
295 ::connectrpc::Context,
296 ),
297 ::connectrpc::ConnectError,
298 > {
299 let req = request.to_owned_message();
300 let message = req.message.into_option().ok_or_else(|| {
301 ::connectrpc::ConnectError::new(
302 ::connectrpc::ErrorCode::InvalidArgument,
303 "Missing message".to_string(),
304 )
305 })?;
306 let config = req.configuration.into_option();
307
308 let request_ctx = request_context(&ctx, &message.context_id);
309 let stamp_ids = resume::wants_event_ids(&ctx.headers);
310
311 let opts = decode_send_config(config);
316
317 let (task, update_stream) = self
318 .service
319 .send_streaming_message(message, &request_ctx, opts.push_config, opts.history_limit)
320 .await
321 .map_err(map_err)?;
322
323 use futures::StreamExt;
324
325 let initial_response = StreamResponse {
326 payload: Some(stream_response::Payload::Task(Box::new(task))),
327 ..Default::default()
328 };
329
330 let chained_stream = futures::stream::once(async { Ok(initial_response) })
333 .chain(wire_stream(update_stream, stamp_ids));
334
335 Ok((Box::pin(chained_stream), ctx))
336 }
337
338 async fn get_task(
339 &self,
340 ctx: ::connectrpc::Context,
341 request: ::buffa::view::OwnedView<GetTaskRequestView<'static>>,
342 ) -> Result<(Task, ::connectrpc::Context), ::connectrpc::ConnectError> {
343 let req = request.to_owned_message();
344 let history_length = req.history_length.map(|l| l as u32);
345 let id: TaskId = req.id.parse().map_err(map_err)?;
346 let task = self
347 .service
348 .get(&id, history_length)
349 .await
350 .map_err(map_err)?;
351 Ok((task, ctx))
352 }
353
354 async fn list_tasks(
355 &self,
356 ctx: ::connectrpc::Context,
357 request: ::buffa::view::OwnedView<ListTasksRequestView<'static>>,
358 ) -> Result<(ListTasksResponse, ::connectrpc::Context), ::connectrpc::ConnectError> {
359 let req = request.to_owned_message();
360 let params = list_request_to_params(req);
361
362 let result = self.service.list(¶ms).await.map_err(map_err)?;
363
364 let response = ListTasksResponse {
365 tasks: result.tasks,
366 next_page_token: result.next_page_token,
367 page_size: result.page_size,
368 total_size: result.total_size,
369 ..Default::default()
370 };
371
372 Ok((response, ctx))
373 }
374
375 async fn cancel_task(
376 &self,
377 ctx: ::connectrpc::Context,
378 request: ::buffa::view::OwnedView<CancelTaskRequestView<'static>>,
379 ) -> Result<(Task, ::connectrpc::Context), ::connectrpc::ConnectError> {
380 let req = request.to_owned_message();
381 let id: TaskId = req.id.parse().map_err(map_err)?;
382 let task = self.service.cancel(&id).await.map_err(map_err)?;
383 Ok((task, ctx))
384 }
385
386 #[allow(clippy::result_large_err)]
387 async fn subscribe_to_task(
388 &self,
389 ctx: ::connectrpc::Context,
390 request: ::buffa::view::OwnedView<SubscribeToTaskRequestView<'static>>,
391 ) -> Result<
392 (
393 ::std::pin::Pin<
394 Box<
395 dyn ::futures::Stream<Item = Result<StreamResponse, ::connectrpc::ConnectError>>
396 + Send,
397 >,
398 >,
399 ::connectrpc::Context,
400 ),
401 ::connectrpc::ConnectError,
402 > {
403 let req = request.to_owned_message();
404 let from_event_id = resume::parse_last_event_id(&ctx.headers);
405 let stamp_ids = resume::wants_event_ids(&ctx.headers);
406
407 let (initial_task, update_stream) = self
408 .service
409 .subscribe(&req.id, from_event_id)
410 .await
411 .map_err(map_err)?;
412
413 use futures::StreamExt;
414
415 let mapped_stream = wire_stream(update_stream, stamp_ids);
416
417 if let Some(task) = initial_task {
418 let initial_response = StreamResponse {
419 payload: Some(stream_response::Payload::Task(Box::new(task))),
420 ..Default::default()
421 };
422 let chained_stream =
423 futures::stream::once(async { Ok(initial_response) }).chain(mapped_stream);
424 Ok((Box::pin(chained_stream), ctx))
425 } else {
426 Ok((Box::pin(mapped_stream), ctx))
427 }
428 }
429
430 async fn create_task_push_notification_config(
431 &self,
432 ctx: ::connectrpc::Context,
433 request: ::buffa::view::OwnedView<TaskPushNotificationConfigView<'static>>,
434 ) -> Result<(TaskPushNotificationConfig, ::connectrpc::Context), ::connectrpc::ConnectError>
435 {
436 let config = request.to_owned_message();
437 let created_config = self
438 .service
439 .set_push_config(&config)
440 .await
441 .map_err(map_err)?;
442 Ok((created_config, ctx))
443 }
444
445 async fn get_task_push_notification_config(
446 &self,
447 ctx: ::connectrpc::Context,
448 request: ::buffa::view::OwnedView<GetTaskPushNotificationConfigRequestView<'static>>,
449 ) -> Result<(TaskPushNotificationConfig, ::connectrpc::Context), ::connectrpc::ConnectError>
450 {
451 let req = request.to_owned_message();
452 let params = crate::domain::GetTaskPushNotificationConfigParams {
453 id: req.task_id,
454 push_notification_config_id: Some(req.id),
455 metadata: None,
456 };
457 let config = self
458 .service
459 .get_push_config(¶ms)
460 .await
461 .map_err(map_err)?;
462 Ok((config, ctx))
463 }
464
465 async fn list_task_push_notification_configs(
466 &self,
467 ctx: ::connectrpc::Context,
468 request: ::buffa::view::OwnedView<ListTaskPushNotificationConfigsRequestView<'static>>,
469 ) -> Result<
470 (
471 ListTaskPushNotificationConfigsResponse,
472 ::connectrpc::Context,
473 ),
474 ::connectrpc::ConnectError,
475 > {
476 let req = request.to_owned_message();
477 let params = crate::domain::ListTaskPushNotificationConfigsParams {
478 id: req.task_id,
479 metadata: None,
480 };
481 let configs = self
482 .service
483 .list_push_configs(¶ms)
484 .await
485 .map_err(map_err)?;
486 let response = ListTaskPushNotificationConfigsResponse {
487 configs,
488 ..Default::default()
489 };
490 Ok((response, ctx))
491 }
492
493 async fn get_extended_agent_card(
494 &self,
495 ctx: ::connectrpc::Context,
496 request: ::buffa::view::OwnedView<GetExtendedAgentCardRequestView<'static>>,
497 ) -> Result<(AgentCard, ::connectrpc::Context), ::connectrpc::ConnectError> {
498 let _req = request.to_owned_message();
499 let card = self.service.extended_agent_card().await.map_err(map_err)?;
500 Ok((card, ctx))
501 }
502
503 async fn delete_task_push_notification_config(
504 &self,
505 ctx: ::connectrpc::Context,
506 request: ::buffa::view::OwnedView<DeleteTaskPushNotificationConfigRequestView<'static>>,
507 ) -> Result<
508 (
509 ::buffa_types::google::protobuf::Empty,
510 ::connectrpc::Context,
511 ),
512 ::connectrpc::ConnectError,
513 > {
514 let req = request.to_owned_message();
515 let params = crate::domain::DeleteTaskPushNotificationConfigParams {
516 id: req.task_id,
517 push_notification_config_id: req.id,
518 metadata: None,
519 };
520 self.service
521 .delete_push_config(¶ms)
522 .await
523 .map_err(map_err)?;
524 Ok((::buffa_types::google::protobuf::Empty::default(), ctx))
525 }
526}
527
528pub(super) fn list_request_to_params(req: ListTasksRequest) -> crate::domain::ListTasksParams {
531 crate::domain::ListTasksParams {
532 context_id: if req.context_id.is_empty() {
533 None
534 } else {
535 Some(req.context_id)
536 },
537 status: match req.status.to_i32() {
538 0 => None,
539 val => Some(TaskState::from_i32(val).unwrap_or(TaskState::TASK_STATE_UNSPECIFIED)),
540 },
541 page_size: req.page_size,
542 page_token: if req.page_token.is_empty() {
543 None
544 } else {
545 Some(req.page_token)
546 },
547 history_length: req.history_length,
548 include_artifacts: req.include_artifacts,
549 status_timestamp_after: req.status_timestamp_after.as_option().map(|t| {
550 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(t.seconds, t.nanos as u32)
551 .unwrap_or_default();
552 dt.to_rfc3339()
553 }),
554 metadata: None,
555 }
556}
557
558pub(super) fn decode_send_config(
570 config: Option<crate::domain::generated::SendMessageConfiguration>,
571) -> SendOptions {
572 let Some(c) = config else {
573 return SendOptions::default();
574 };
575 SendOptions {
576 push_config: c.task_push_notification_config.into_option(),
577 history_limit: c.history_length.map(|limit| limit as u32),
578 completion: if c.return_immediately {
579 SendCompletion::WhenCreated
580 } else {
581 SendCompletion::WhenSettled
582 },
583 }
584}
585
586#[derive(Clone, Debug, Default)]
589pub struct NoopStreamingHandler;
590
591#[async_trait]
592impl AsyncStreamingHandler for NoopStreamingHandler {
593 async fn add_status_subscriber(
594 &self,
595 _task_id: &str,
596 _subscriber: Box<dyn Subscriber<TaskStatusUpdateEvent> + Send + Sync>,
597 ) -> Result<String, A2AError> {
598 Err(A2AError::UnsupportedOperation(
599 "Streaming not supported by this processor".to_string(),
600 ))
601 }
602
603 async fn add_artifact_subscriber(
604 &self,
605 _task_id: &str,
606 _subscriber: Box<dyn Subscriber<TaskArtifactUpdateEvent> + Send + Sync>,
607 ) -> Result<String, A2AError> {
608 Err(A2AError::UnsupportedOperation(
609 "Streaming not supported by this processor".to_string(),
610 ))
611 }
612
613 async fn remove_subscription(&self, _subscription_id: &str) -> Result<(), A2AError> {
614 Ok(())
615 }
616
617 async fn remove_task_subscribers(&self, _task_id: &str) -> Result<(), A2AError> {
618 Ok(())
619 }
620
621 async fn get_subscriber_count(&self, _task_id: &str) -> Result<usize, A2AError> {
622 Ok(0)
623 }
624
625 async fn broadcast_status_update(
626 &self,
627 _task_id: &str,
628 _update: TaskStatusUpdateEvent,
629 ) -> Result<(), A2AError> {
630 Ok(())
631 }
632
633 async fn broadcast_artifact_update(
634 &self,
635 _task_id: &str,
636 _update: TaskArtifactUpdateEvent,
637 ) -> Result<(), A2AError> {
638 Ok(())
639 }
640
641 async fn status_update_stream(
642 &self,
643 _task_id: &str,
644 ) -> Result<
645 Pin<Box<dyn ::futures::Stream<Item = Result<TaskStatusUpdateEvent, A2AError>> + Send>>,
646 A2AError,
647 > {
648 Err(A2AError::UnsupportedOperation(
649 "Streaming not supported by this processor".to_string(),
650 ))
651 }
652
653 async fn artifact_update_stream(
654 &self,
655 _task_id: &str,
656 ) -> Result<
657 Pin<Box<dyn ::futures::Stream<Item = Result<TaskArtifactUpdateEvent, A2AError>> + Send>>,
658 A2AError,
659 > {
660 Err(A2AError::UnsupportedOperation(
661 "Streaming not supported by this processor".to_string(),
662 ))
663 }
664
665 async fn combined_update_stream(
666 &self,
667 _task_id: &str,
668 _from_event_id: Option<u64>,
669 ) -> Result<Pin<Box<dyn ::futures::Stream<Item = Result<SeqEvent, A2AError>> + Send>>, A2AError>
670 {
671 Err(A2AError::UnsupportedOperation(
672 "Streaming not supported by this processor".to_string(),
673 ))
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
687 fn the_request_context_carries_the_principal_from_the_extensions() {
688 let mut extensions = ::http::Extensions::new();
689 extensions.insert(AuthPrincipal::new("alice".to_string(), "jwt".to_string()));
690 let ctx = ::connectrpc::Context::new(::http::HeaderMap::new()).with_extensions(extensions);
691
692 let request_ctx = request_context(&ctx, "ctx-1");
693
694 assert_eq!(request_ctx.caller(), Some("alice"));
695 assert_eq!(request_ctx.session_id(), Some("ctx-1"));
696 }
697
698 #[test]
701 fn an_unauthenticated_call_names_nobody() {
702 let ctx = ::connectrpc::Context::new(::http::HeaderMap::new());
703
704 let request_ctx = request_context(&ctx, "");
705
706 assert_eq!(request_ctx.caller(), None);
707 assert_eq!(request_ctx.session_id(), None);
708 }
709}