1use crate::agent::Laser;
2use crate::bridge_hops::hops_metadata;
3use crate::error::LaserError;
4use crate::provenance::AgentTopic;
5use crate::types::ConversationId;
6#[cfg(feature = "a2a-http")]
7use axum::Router;
8#[cfg(feature = "a2a-http")]
9use axum::extract::State;
10#[cfg(feature = "a2a-http")]
11use axum::routing::post;
12use laser_wire::agent::{
13 self as agdx, AgentEnvelope, AgentId, CapabilityDescriptor, ContentRef, CorrelationId,
14 METADATA_BRIDGE_HOPS, OPERATION_CHAT,
15};
16use laser_wire::content::ContentType;
17use serde::{Deserialize, Serialize};
18use serde_json::Value as JsonValue;
19use serde_json::to_value;
20use std::str::FromStr;
21#[cfg(feature = "a2a-http")]
22use std::sync::Arc;
23use strum::{Display, EnumString};
24
25pub use crate::bridge_hops::enter_bridge;
26pub use laser_wire::agent::TaskState;
27
28const JSONRPC_VERSION: &str = "2.0";
29const APP_ERROR_CODE: i32 = -32000;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)]
39pub enum A2aMethod {
40 #[strum(serialize = "SendMessage")]
41 MessageSend,
42 #[strum(serialize = "SendStreamingMessage")]
43 MessageStream,
44 #[strum(serialize = "GetTask")]
45 TasksGet,
46 #[strum(serialize = "CancelTask")]
47 TasksCancel,
48}
49
50pub const A2A_PROTOCOL_VERSION: &str = "1.0";
52
53pub const A2A_JSONRPC_BINDING: &str = "JSONRPC";
55
56#[derive(Debug, Clone, Serialize)]
62pub struct AgentCard {
63 pub name: String,
64 pub description: String,
65 pub version: String,
66 #[serde(rename = "supportedInterfaces")]
67 pub supported_interfaces: Vec<AgentInterface>,
68 pub capabilities: AgentCardCapabilities,
69 #[serde(rename = "defaultInputModes")]
70 pub default_input_modes: Vec<String>,
71 #[serde(rename = "defaultOutputModes")]
72 pub default_output_modes: Vec<String>,
73 pub skills: Vec<AgentSkill>,
74 #[serde(skip_serializing_if = "Vec::is_empty", default)]
77 pub signatures: Vec<AgentCardSignature>,
78}
79
80#[derive(Debug, Clone, Serialize)]
82pub struct AgentInterface {
83 pub url: String,
84 #[serde(rename = "protocolBinding")]
85 pub protocol_binding: String,
86 #[serde(rename = "protocolVersion")]
87 pub protocol_version: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct AgentCardSignature {
95 pub protected: String,
97 pub signature: String,
99}
100
101#[derive(Debug, Clone, Serialize)]
103pub struct AgentCardCapabilities {
104 pub streaming: bool,
107 #[serde(rename = "pushNotifications")]
108 pub push_notifications: bool,
109 #[serde(rename = "stateTransitionHistory")]
110 pub state_transition_history: bool,
111 #[serde(rename = "extendedAgentCard")]
114 pub extended_agent_card: bool,
115}
116
117#[derive(Debug, Clone, Serialize)]
119pub struct AgentSkill {
120 pub id: String,
121 pub name: String,
122 pub description: String,
123 pub tags: Vec<String>,
124 #[serde(rename = "inputModes", skip_serializing_if = "Vec::is_empty")]
127 pub input_modes: Vec<String>,
128 #[serde(rename = "outputModes", skip_serializing_if = "Vec::is_empty")]
130 pub output_modes: Vec<String>,
131}
132
133fn content_ref_mode(reference: &ContentRef) -> String {
137 match reference {
138 ContentRef::ContentType(content_type) => match content_type {
139 ContentType::Raw => "application/octet-stream",
140 ContentType::Json => "application/json",
141 ContentType::Msgpack => "application/msgpack",
142 ContentType::Cbor => "application/cbor",
143 ContentType::Bson => "application/bson",
144 ContentType::Avro => "application/avro",
145 ContentType::Protobuf => "application/protobuf",
146 ContentType::Arrow => "application/vnd.apache.arrow.stream",
147 ContentType::Ref => "application/x-agdx-ref",
148 ContentType::Any => "*/*",
149 _ => "application/octet-stream",
150 }
151 .to_owned(),
152 ContentRef::SchemaId(id) => format!("application/x-agdx-schema;id={id}"),
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct TaskStatus {
161 #[serde(with = "task_state_json")]
162 pub state: TaskState,
163}
164
165mod task_state_json {
169 use super::TaskState;
170 use serde::de::Error;
171 use serde::{Deserialize, Deserializer, Serializer};
172
173 pub fn serialize<S: Serializer>(state: &TaskState, serializer: S) -> Result<S::Ok, S::Error> {
174 serializer.collect_str(state)
175 }
176
177 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<TaskState, D::Error> {
178 String::deserialize(deserializer)?
179 .parse()
180 .map_err(D::Error::custom)
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct Artifact {
187 pub text: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct Task {
193 pub id: String,
194 pub status: TaskStatus,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub artifacts: Vec<Artifact>,
197}
198
199#[derive(Debug, Deserialize)]
201pub struct JsonRpcRequest {
202 #[serde(default)]
203 pub id: JsonValue,
204 pub method: String,
205 #[serde(default)]
206 pub params: JsonValue,
207}
208
209#[derive(Debug, Serialize)]
211pub struct JsonRpcResponse {
212 pub jsonrpc: &'static str,
213 pub id: JsonValue,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub result: Option<JsonValue>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub error: Option<JsonRpcError>,
218}
219
220#[derive(Debug, Serialize)]
222pub struct JsonRpcError {
223 pub code: i32,
224 pub message: String,
225}
226
227pub fn command_from_message_send(
233 record: agdx::RecordId,
234 conversation: agdx::ConversationId,
235 source: agdx::AgentId,
236 correlation: agdx::CorrelationId,
237 params_json: Vec<u8>,
238) -> AgentEnvelope {
239 AgentEnvelope::command(record, conversation, source, correlation, params_json)
240 .with_operation(OPERATION_CHAT)
241}
242
243pub fn task_from_envelope(task_id: impl Into<String>, envelope: &AgentEnvelope) -> Task {
248 let state = envelope.task_state.unwrap_or(match envelope.kind {
249 agdx::AgentKind::Error => TaskState::Failed,
250 _ => TaskState::Completed,
251 });
252 let artifacts = if envelope.body.is_empty() {
253 Vec::new()
254 } else {
255 vec![Artifact {
256 text: String::from_utf8_lossy(&envelope.body).into_owned(),
257 }]
258 };
259 Task {
260 id: task_id.into(),
261 status: TaskStatus { state },
262 artifacts,
263 }
264}
265
266pub struct A2aBridge {
271 laser: Laser,
272 source: AgentId,
273 request_topic: AgentTopic<'static>,
274 reply_topic: AgentTopic<'static>,
275 capabilities: Vec<CapabilityDescriptor>,
276 hops: Vec<String>,
279 #[cfg(feature = "sign")]
283 signing_key: Option<crate::sign::SigningKey>,
284 task_binding: Option<TaskBinding>,
286}
287
288#[derive(Clone)]
290struct TaskBinding {
291 key: [u8; 32],
292 ttl: std::time::Duration,
293}
294
295impl TaskBinding {
296 fn mac(
300 &self,
301 caller: &str,
302 stream_generation: u64,
303 task: ConversationId,
304 expires: u64,
305 ) -> blake3::Hash {
306 let mut hasher = blake3::Hasher::new_keyed(&self.key);
307 hasher.update(b"laserdata a2a task v1\0");
308 hasher.update(&(caller.len() as u64).to_le_bytes());
309 hasher.update(caller.as_bytes());
310 hasher.update(&stream_generation.to_le_bytes());
311 hasher.update(&task.as_u128().to_le_bytes());
312 hasher.update(&expires.to_le_bytes());
313 hasher.finalize()
314 }
315}
316
317#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct A2aCallerPrincipal(pub String);
322
323pub struct A2aCaller<'a> {
326 bridge: &'a A2aBridge,
327 principal: String,
328}
329
330impl A2aCaller<'_> {
331 pub async fn submit(&self, params_json: Vec<u8>) -> Result<Task, LaserError> {
333 self.bridge
334 .submit_addressed(None, None, params_json, Some(&self.principal))
335 .await
336 }
337
338 pub async fn submit_to(
340 &self,
341 target: impl Into<AgentId>,
342 params_json: Vec<u8>,
343 ) -> Result<Task, LaserError> {
344 self.bridge
345 .submit_addressed(
346 Some(target.into()),
347 None,
348 params_json,
349 Some(&self.principal),
350 )
351 .await
352 }
353
354 pub async fn submit_in(
356 &self,
357 parent: ConversationId,
358 root: ConversationId,
359 params_json: Vec<u8>,
360 ) -> Result<Task, LaserError> {
361 self.bridge
362 .submit_addressed(
363 None,
364 Some((parent, root)),
365 params_json,
366 Some(&self.principal),
367 )
368 .await
369 }
370
371 pub async fn submit_in_to(
373 &self,
374 target: impl Into<AgentId>,
375 parent: ConversationId,
376 root: ConversationId,
377 params_json: Vec<u8>,
378 ) -> Result<Task, LaserError> {
379 self.bridge
380 .submit_addressed(
381 Some(target.into()),
382 Some((parent, root)),
383 params_json,
384 Some(&self.principal),
385 )
386 .await
387 }
388
389 pub async fn task(&self, id: &str) -> Result<Task, LaserError> {
392 self.bridge.task_for(id, Some(&self.principal)).await
393 }
394
395 pub async fn cancel(&self, id: &str) -> Result<Task, LaserError> {
398 self.bridge.cancel_for(id, Some(&self.principal)).await
399 }
400
401 pub async fn handle_rpc(&self, request: JsonValue) -> JsonRpcResponse {
403 self.bridge
404 .handle_rpc_for(request, Some(&self.principal))
405 .await
406 }
407}
408
409impl A2aBridge {
410 pub fn new(
417 laser: Laser,
418 source: impl Into<AgentId>,
419 request_topic: AgentTopic<'static>,
420 reply_topic: AgentTopic<'static>,
421 ) -> Self {
422 let source = source.into();
423 let hops = vec![source.as_str().to_owned()];
424 Self {
425 laser,
426 source,
427 request_topic,
428 reply_topic,
429 capabilities: Vec::new(),
430 hops,
431 #[cfg(feature = "sign")]
432 signing_key: None,
433 task_binding: None,
434 }
435 }
436
437 #[must_use]
444 pub fn with_task_binding(mut self, key: [u8; 32], ttl: std::time::Duration) -> Self {
445 self.task_binding = Some(TaskBinding { key, ttl });
446 self
447 }
448
449 pub fn caller(&self, principal: impl Into<String>) -> A2aCaller<'_> {
451 A2aCaller {
452 bridge: self,
453 principal: principal.into(),
454 }
455 }
456
457 async fn stream_generation(&self) -> Result<u64, LaserError> {
460 let stream = iggy::prelude::Identifier::named(self.laser.stream_required()?)?;
461 let details =
462 iggy::prelude::StreamClient::get_stream(self.laser.client().as_ref(), &stream)
463 .await?
464 .ok_or_else(|| {
465 LaserError::HandlerConfig("the bridge stream does not exist".to_owned())
466 })?;
467 Ok(details.created_at.as_micros())
468 }
469
470 async fn task_id(
472 &self,
473 task: ConversationId,
474 caller: Option<&str>,
475 ) -> Result<String, LaserError> {
476 let Some(binding) = &self.task_binding else {
477 return Ok(task.to_string());
478 };
479 let caller = caller.ok_or_else(unbound_caller)?;
480 let generation = self.stream_generation().await?;
481 let expires =
482 now_micros().saturating_add(u64::try_from(binding.ttl.as_micros()).unwrap_or(u64::MAX));
483 let mac = binding.mac(caller, generation, task, expires);
484 Ok(format!("{task}.{expires}.{}", mac.to_hex()))
485 }
486
487 async fn open_task(
490 &self,
491 id: &str,
492 caller: Option<&str>,
493 ) -> Result<ConversationId, LaserError> {
494 let invalid = || LaserError::HandlerConfig(format!("invalid task id `{id}`"));
495 let Some(binding) = &self.task_binding else {
496 return id.parse::<ConversationId>().map_err(|_| invalid());
497 };
498 let caller = caller.ok_or_else(unbound_caller)?;
499 let mut parts = id.split('.');
500 let (Some(task), Some(expires), Some(mac), None) =
501 (parts.next(), parts.next(), parts.next(), parts.next())
502 else {
503 return Err(invalid());
504 };
505 let task = task.parse::<ConversationId>().map_err(|_| invalid())?;
506 let expires = expires.parse::<u64>().map_err(|_| invalid())?;
507 let mac = blake3::Hash::from_hex(mac).map_err(|_| invalid())?;
508 if expires < now_micros() {
509 return Err(invalid());
510 }
511 let generation = self.stream_generation().await?;
512 if binding.mac(caller, generation, task, expires) != mac {
514 return Err(invalid());
515 }
516 Ok(task)
517 }
518
519 pub fn with_bridge_hops(mut self, previous: &[String]) -> Result<Self, LaserError> {
525 self.hops = enter_bridge(self.source.as_str(), previous)?;
526 Ok(self)
527 }
528
529 #[cfg(feature = "sign")]
534 #[must_use]
535 pub fn with_signing_key(mut self, key: crate::sign::SigningKey) -> Self {
536 self.signing_key = Some(key);
537 self
538 }
539
540 #[must_use]
545 pub fn with_capabilities(mut self, capabilities: Vec<CapabilityDescriptor>) -> Self {
546 self.capabilities = capabilities;
547 self
548 }
549
550 pub async fn submit(&self, params_json: Vec<u8>) -> Result<Task, LaserError> {
558 self.submit_addressed(None, None, params_json, None).await
559 }
560
561 pub async fn submit_to(
564 &self,
565 target: impl Into<AgentId>,
566 params_json: Vec<u8>,
567 ) -> Result<Task, LaserError> {
568 let target = target.into();
569 self.submit_addressed(Some(target), None, params_json, None)
570 .await
571 }
572
573 pub async fn submit_in(
579 &self,
580 parent: ConversationId,
581 root: ConversationId,
582 params_json: Vec<u8>,
583 ) -> Result<Task, LaserError> {
584 self.submit_addressed(None, Some((parent, root)), params_json, None)
585 .await
586 }
587
588 pub async fn submit_in_to(
590 &self,
591 target: impl Into<AgentId>,
592 parent: ConversationId,
593 root: ConversationId,
594 params_json: Vec<u8>,
595 ) -> Result<Task, LaserError> {
596 let target = target.into();
597 self.submit_addressed(Some(target), Some((parent, root)), params_json, None)
598 .await
599 }
600
601 async fn submit_addressed(
602 &self,
603 target: Option<AgentId>,
604 ancestry: Option<(ConversationId, ConversationId)>,
605 params_json: Vec<u8>,
606 caller: Option<&str>,
607 ) -> Result<Task, LaserError> {
608 let task = ConversationId::new();
609 let id = self.task_id(task, caller).await?;
612 if let Some((parent, root)) = ancestry {
613 crate::agent::contract::start_child(
614 &self.laser,
615 &self.source,
616 &self.source,
617 task.into(),
618 parent.into(),
619 root.into(),
620 )
621 .await?;
622 }
623 let producer =
624 self.laser
625 .agdx(self.request_topic.clone(), self.source.clone(), task.into());
626 let mut command = producer
627 .command(correlation_of(task), params_json)
628 .with_operation(OPERATION_CHAT)
629 .with_metadata(METADATA_BRIDGE_HOPS, hops_metadata(&self.hops))
630 .content_type(ContentType::Json);
631 if let Some((parent, root)) = ancestry {
632 command = command.with_ancestry(Some(parent.into()), Some(root.into()));
633 }
634 if let Some(target) = target {
635 command = command.with_target(target);
636 }
637 command.send().await?;
638 Ok(Task {
639 id,
640 status: TaskStatus {
641 state: TaskState::Submitted,
642 },
643 artifacts: Vec::new(),
644 })
645 }
646
647 pub async fn task(&self, id: &str) -> Result<Task, LaserError> {
652 self.task_for(id, None).await
653 }
654
655 async fn task_for(&self, id: &str, caller: Option<&str>) -> Result<Task, LaserError> {
656 let conversation = self.open_task(id, caller).await?;
657 let correlation = correlation_of(conversation);
658 let answer = self
661 .laser
662 .find_agdx_reply(self.reply_topic.clone(), correlation)
663 .await?;
664 Ok(match answer {
665 crate::agent::ReplyLookup::Found(envelope) => task_from_envelope(id, &envelope),
666 crate::agent::ReplyLookup::NotFound => Task {
667 id: id.to_owned(),
668 status: TaskStatus {
669 state: TaskState::Working,
670 },
671 artifacts: Vec::new(),
672 },
673 crate::agent::ReplyLookup::Incomplete => Task {
677 id: id.to_owned(),
678 status: TaskStatus {
679 state: TaskState::Unknown,
680 },
681 artifacts: Vec::new(),
682 },
683 })
684 }
685
686 pub async fn cancel(&self, id: &str) -> Result<Task, LaserError> {
690 self.cancel_for(id, None).await
691 }
692
693 async fn cancel_for(&self, id: &str, caller: Option<&str>) -> Result<Task, LaserError> {
694 let conversation = self.open_task(id, caller).await?;
696 let error = agdx::AgentErrorBody {
697 code: agdx::AgentErrorCode::Cancelled,
698 message: Some("canceled by the A2A client".to_owned()),
699 retryable: false,
700 detail: None,
701 };
702 let producer = self.laser.agdx(
703 self.reply_topic.clone(),
704 self.source.clone(),
705 conversation.into(),
706 );
707 let send = producer
708 .fail(correlation_of(conversation), &error)?
709 .with_task_state(TaskState::Canceled)
710 .with_metadata(METADATA_BRIDGE_HOPS, hops_metadata(&self.hops));
711 #[cfg(feature = "sign")]
715 let send = match &self.signing_key {
716 Some(key) => send.signed_by(key),
717 None => send,
718 };
719 send.send().await?;
720 Ok(Task {
721 id: id.to_owned(),
722 status: TaskStatus {
723 state: TaskState::Canceled,
724 },
725 artifacts: Vec::new(),
726 })
727 }
728
729 pub fn card(&self) -> AgentCard {
735 let skills = self
736 .capabilities
737 .iter()
738 .map(|capability| AgentSkill {
739 id: capability.skill_id.clone(),
740 name: capability.skill_id.clone(),
741 description: String::new(),
742 tags: Vec::new(),
743 input_modes: capability.input.iter().map(content_ref_mode).collect(),
744 output_modes: capability.output.iter().map(content_ref_mode).collect(),
745 })
746 .collect();
747 AgentCard {
748 name: self.source.as_str().to_owned(),
749 description: "LaserData AGDX bridge over the durable log".to_owned(),
750 version: env!("CARGO_PKG_VERSION").to_owned(),
751 supported_interfaces: vec![AgentInterface {
752 url: "/".to_owned(),
753 protocol_binding: A2A_JSONRPC_BINDING.to_owned(),
754 protocol_version: A2A_PROTOCOL_VERSION.to_owned(),
755 }],
756 capabilities: AgentCardCapabilities {
757 streaming: true,
758 push_notifications: false,
759 state_transition_history: false,
760 extended_agent_card: false,
761 },
762 default_input_modes: vec!["text/plain".to_owned()],
763 default_output_modes: vec!["text/plain".to_owned()],
764 skills,
765 signatures: Vec::new(),
766 }
767 }
768
769 #[cfg(feature = "sign")]
775 pub fn signed_card(&self, key: &crate::sign::SigningKey) -> Result<AgentCard, LaserError> {
776 let mut card = self.card();
777 let value = serde_json::to_value(&card)
778 .map_err(|error| LaserError::Codec(format!("card to JSON: {error}")))?;
779 let signature = crate::sign::sign_card_value(key, &value)?;
780 card.signatures.push(signature);
781 Ok(card)
782 }
783
784 pub async fn handle_rpc(&self, request: JsonValue) -> JsonRpcResponse {
785 self.handle_rpc_for(request, None).await
786 }
787
788 async fn handle_rpc_for(&self, request: JsonValue, caller: Option<&str>) -> JsonRpcResponse {
789 let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
790 match self.dispatch_rpc(&request, caller).await {
791 Ok(result) => JsonRpcResponse {
792 jsonrpc: JSONRPC_VERSION,
793 id,
794 result: Some(result),
795 error: None,
796 },
797 Err(error) => {
798 tracing::warn!(error = %error, "A2A request failed");
799 JsonRpcResponse {
800 jsonrpc: JSONRPC_VERSION,
801 id,
802 result: None,
803 error: Some(JsonRpcError {
804 code: APP_ERROR_CODE,
805 message: crate::error::public_error_message(&error).to_owned(),
806 }),
807 }
808 }
809 }
810 }
811
812 async fn dispatch_rpc(
813 &self,
814 request: &JsonValue,
815 caller: Option<&str>,
816 ) -> Result<JsonValue, LaserError> {
817 if !request.is_object() {
819 return Err(LaserError::HandlerConfig(
820 "A2A JSON-RPC request must be an object".to_owned(),
821 ));
822 }
823 let JsonRpcRequest { method, params, .. } = JsonRpcRequest::deserialize(request)
824 .map_err(|_| LaserError::HandlerConfig("A2A method must be a string".to_owned()))?;
825 let params = if params.is_null() {
826 JsonValue::Object(Default::default())
827 } else {
828 params
829 };
830 let task = match A2aMethod::from_str(&method) {
831 Ok(A2aMethod::MessageSend | A2aMethod::MessageStream) => {
832 let body = serde_json::to_vec(¶ms)
833 .map_err(|error| LaserError::Codec(error.to_string()))?;
834 self.submit_addressed(None, None, body, caller).await?
835 }
836 Ok(operation @ (A2aMethod::TasksGet | A2aMethod::TasksCancel)) => {
837 let id = params
838 .as_object()
839 .and_then(|params| params.get("id"))
840 .and_then(JsonValue::as_str)
841 .ok_or_else(|| {
842 LaserError::HandlerConfig("A2A task id must be a string".to_owned())
843 })?;
844 if operation == A2aMethod::TasksGet {
845 self.task_for(id, caller).await?
846 } else {
847 self.cancel_for(id, caller).await?
848 }
849 }
850 Err(_) => {
851 return Err(LaserError::HandlerConfig(format!(
852 "unknown A2A method `{method}`"
853 )));
854 }
855 };
856 to_value(task).map_err(|error| LaserError::Codec(error.to_string()))
857 }
858
859 #[cfg(feature = "a2a-http")]
863 pub fn router(self: Arc<Self>) -> Router {
864 Router::new()
865 .route("/", post(handle_rpc))
866 .route(
867 "/.well-known/agent-card.json",
868 axum::routing::get(handle_card),
869 )
870 .with_state(self)
871 }
872}
873
874#[cfg(feature = "a2a-http")]
875async fn handle_card(State(bridge): State<Arc<A2aBridge>>) -> axum::Json<AgentCard> {
876 axum::Json(bridge.card())
877}
878
879fn correlation_of(conversation: ConversationId) -> CorrelationId {
883 CorrelationId::from_u128(conversation.as_u128())
884}
885
886#[cfg(feature = "a2a-http")]
887async fn handle_rpc(
888 State(bridge): State<Arc<A2aBridge>>,
889 principal: Option<axum::Extension<A2aCallerPrincipal>>,
890 axum::Json(request): axum::Json<JsonValue>,
891) -> axum::Json<JsonRpcResponse> {
892 let caller = principal.as_ref().map(|principal| principal.0.0.as_str());
893 axum::Json(bridge.handle_rpc_for(request, caller).await)
894}
895
896fn now_micros() -> u64 {
897 std::time::SystemTime::now()
898 .duration_since(std::time::UNIX_EPOCH)
899 .map_or(0, |elapsed| {
900 u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX)
901 })
902}
903
904fn unbound_caller() -> LaserError {
905 LaserError::HandlerConfig(
906 "this bridge binds tasks to callers, so the request needs an authenticated caller"
907 .to_owned(),
908 )
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914 use laser_wire::framing::{decode_named, encode_named};
915 use serde_json::{from_str, to_string};
916
917 #[test]
918 fn given_a_task_binding_when_any_bound_field_changes_then_the_digest_should_change() {
919 let binding = TaskBinding {
920 key: [7; 32],
921 ttl: std::time::Duration::from_secs(60),
922 };
923 let task = ConversationId::derive("task-42");
924 let base = binding.mac("alice", 10, task, 1_000);
925 assert_eq!(base, binding.mac("alice", 10, task, 1_000));
926 assert_ne!(base, binding.mac("bob", 10, task, 1_000), "another caller");
927 assert_ne!(
928 base,
929 binding.mac("alice", 11, task, 1_000),
930 "a recreated stream"
931 );
932 assert_ne!(
933 base,
934 binding.mac("alice", 10, ConversationId::derive("task-43"), 1_000),
935 "another task"
936 );
937 assert_ne!(
938 base,
939 binding.mac("alice", 10, task, 2_000),
940 "another expiry"
941 );
942 let other_key = TaskBinding {
943 key: [8; 32],
944 ..binding.clone()
945 };
946 assert_ne!(base, other_key.mac("alice", 10, task, 1_000), "another key");
947 assert_ne!(
949 binding.mac("ab", 10, task, 1),
950 binding.mac("a", 10, task, 1)
951 );
952 assert_eq!(
954 base.to_hex().as_str(),
955 "d1ea2040a6c1451ff74ad5eb597715ce67fa2d144aa0a2b369d9e56ebf0fee1b"
956 );
957 }
958
959 #[test]
960 fn given_task_states_when_serialized_then_should_use_the_kebab_case_wire_names() {
961 let status = TaskStatus {
962 state: TaskState::InputRequired,
963 };
964 assert_eq!(
965 to_string(&status).expect("status serializes"),
966 r#"{"state":"input-required"}"#
967 );
968 let back: TaskStatus = from_str(r#"{"state":"auth-required"}"#).expect("status parses");
969 assert_eq!(back.state, TaskState::AuthRequired);
970 assert!(from_str::<TaskStatus>(r#"{"state":"nope"}"#).is_err());
972 }
973
974 #[cfg(feature = "a2a-http")]
975 #[test]
976 fn given_a2a_methods_when_round_tripped_then_should_match_the_wire_names() {
977 assert_eq!(A2aMethod::MessageSend.to_string(), "SendMessage");
978 assert_eq!(
979 "GetTask".parse::<A2aMethod>().expect("the method parses"),
980 A2aMethod::TasksGet
981 );
982 assert!("tasks/get".parse::<A2aMethod>().is_err());
984 assert!("nope/now".parse::<A2aMethod>().is_err());
985 }
986
987 #[test]
988 fn given_a_message_send_when_mapped_then_the_foreign_payload_should_tunnel_byte_identical() {
989 let params = br#"{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]},"metadata":{"trace":"abc"}}"#;
990 let envelope = command_from_message_send(
991 agdx::RecordId::from_u128(1),
992 agdx::ConversationId::from_u128(2),
993 "source-agent".parse().expect("valid agent id"),
994 agdx::CorrelationId::from_u128(4),
995 params.to_vec(),
996 );
997 agdx::validate(&envelope).expect("the mapped command validates");
998 let payload = encode_named(&envelope).expect("encodes");
999 let back: AgentEnvelope = decode_named(&payload).expect("decodes");
1000 assert_eq!(
1001 back.body,
1002 params.to_vec(),
1003 "the tunneled remainder must round-trip byte-identical"
1004 );
1005 assert_eq!(back.correlation, envelope.correlation);
1006
1007 let reply = AgentEnvelope::response(
1009 agdx::RecordId::from_u128(5),
1010 agdx::ConversationId::from_u128(2),
1011 "responder-agent".parse().expect("valid agent id"),
1012 agdx::CorrelationId::from_u128(4),
1013 b"plan ready".to_vec(),
1014 )
1015 .with_task_state(TaskState::Completed);
1016 let task = task_from_envelope("t-1", &reply);
1017 assert_eq!(
1018 to_string(&task).expect("task serializes"),
1019 r#"{"id":"t-1","status":{"state":"completed"},"artifacts":[{"text":"plan ready"}]}"#
1020 );
1021
1022 let failure = AgentEnvelope::error(
1024 agdx::RecordId::from_u128(7),
1025 agdx::ConversationId::from_u128(2),
1026 "responder-agent".parse().expect("valid agent id"),
1027 agdx::CorrelationId::from_u128(4),
1028 b"boom".to_vec(),
1029 );
1030 assert_eq!(
1031 task_from_envelope("t-1", &failure).status.state,
1032 TaskState::Failed
1033 );
1034 }
1035
1036 fn bridge() -> A2aBridge {
1037 let laser = Laser::from_client(iggy::prelude::IggyClient::default());
1038 A2aBridge::new(
1039 laser,
1040 "a2a-edge".parse::<AgentId>().expect("valid agent id"),
1041 AgentTopic::Sessions,
1042 AgentTopic::Sessions,
1043 )
1044 }
1045
1046 #[tokio::test]
1047 async fn given_malformed_rpc_requests_when_dispatched_then_should_echo_ids_and_return_public_errors()
1048 {
1049 for request in [
1050 serde_json::json!(null),
1051 serde_json::json!([]),
1052 serde_json::json!([7, "GetTask", {"id": "invalid"}]),
1053 serde_json::json!({"id": 7}),
1054 serde_json::json!({"id": 7, "method": "unknown"}),
1055 serde_json::json!({"id": 7, "method": "GetTask", "params": {"id": 12}}),
1056 serde_json::json!({"id": 7, "method": "CancelTask", "params": []}),
1057 serde_json::json!({"id": 7, "method": "GetTask", "params": {"id": "invalid"}}),
1058 ] {
1059 let expected = request.get("id").cloned().unwrap_or(JsonValue::Null);
1060 let response = bridge().handle_rpc(request).await;
1061 assert_eq!(response.id, expected);
1062 assert_eq!(response.jsonrpc, "2.0");
1063 assert!(response.result.is_none());
1064 let error = response.error.expect("malformed request returns an error");
1065 assert_eq!(error.code, -32000);
1066 assert_eq!(error.message, "invalid request");
1067 }
1068 }
1069
1070 #[test]
1071 fn given_a_new_bridge_when_built_then_should_stamp_its_own_id_as_the_hop_path() {
1072 assert_eq!(bridge().hops, ["a2a-edge"]);
1073 }
1074
1075 #[test]
1076 fn given_an_upstream_hop_path_when_continued_then_should_append_this_bridge() {
1077 let bridge = bridge()
1078 .with_bridge_hops(&["mcp-edge".to_owned()])
1079 .expect("no loop");
1080 assert_eq!(bridge.hops, ["mcp-edge", "a2a-edge"]);
1081 assert!(matches!(
1082 bridge.with_bridge_hops(&["a2a-edge".to_owned()]),
1083 Err(LaserError::Invalid(_))
1084 ));
1085 }
1086
1087 #[test]
1088 fn given_a_jsonrpc_message_send_when_parsed_then_should_expose_the_text_part() {
1089 let request: JsonRpcRequest = from_str(
1090 r#"{"jsonrpc":"2.0","id":1,"method":"SendMessage",
1091 "params":{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]}}}"#,
1092 )
1093 .expect("the request parses");
1094 assert_eq!(request.method, "SendMessage");
1095 assert_eq!(
1096 request
1097 .params
1098 .pointer("/message/parts/0/text")
1099 .and_then(JsonValue::as_str),
1100 Some("hi")
1101 );
1102 }
1103}