use crate::agent::Laser;
use crate::bridge_hops::hops_metadata;
use crate::error::LaserError;
use crate::provenance::AgentTopic;
use crate::types::ConversationId;
#[cfg(feature = "a2a-http")]
use axum::Router;
#[cfg(feature = "a2a-http")]
use axum::extract::State;
#[cfg(feature = "a2a-http")]
use axum::routing::post;
use laser_wire::agent::{
self as agdx, AgentEnvelope, AgentId, CapabilityDescriptor, ContentRef, CorrelationId,
METADATA_BRIDGE_HOPS, OPERATION_CHAT,
};
use laser_wire::content::ContentType;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use serde_json::to_value;
use std::str::FromStr;
#[cfg(feature = "a2a-http")]
use std::sync::Arc;
use strum::{Display, EnumString};
pub use crate::bridge_hops::enter_bridge;
pub use laser_wire::agent::TaskState;
const JSONRPC_VERSION: &str = "2.0";
const APP_ERROR_CODE: i32 = -32000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)]
pub enum A2aMethod {
#[strum(serialize = "SendMessage")]
MessageSend,
#[strum(serialize = "SendStreamingMessage")]
MessageStream,
#[strum(serialize = "GetTask")]
TasksGet,
#[strum(serialize = "CancelTask")]
TasksCancel,
}
pub const A2A_PROTOCOL_VERSION: &str = "1.0";
pub const A2A_JSONRPC_BINDING: &str = "JSONRPC";
#[derive(Debug, Clone, Serialize)]
pub struct AgentCard {
pub name: String,
pub description: String,
pub version: String,
#[serde(rename = "supportedInterfaces")]
pub supported_interfaces: Vec<AgentInterface>,
pub capabilities: AgentCardCapabilities,
#[serde(rename = "defaultInputModes")]
pub default_input_modes: Vec<String>,
#[serde(rename = "defaultOutputModes")]
pub default_output_modes: Vec<String>,
pub skills: Vec<AgentSkill>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub signatures: Vec<AgentCardSignature>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentInterface {
pub url: String,
#[serde(rename = "protocolBinding")]
pub protocol_binding: String,
#[serde(rename = "protocolVersion")]
pub protocol_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCardSignature {
pub protected: String,
pub signature: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentCardCapabilities {
pub streaming: bool,
#[serde(rename = "pushNotifications")]
pub push_notifications: bool,
#[serde(rename = "stateTransitionHistory")]
pub state_transition_history: bool,
#[serde(rename = "extendedAgentCard")]
pub extended_agent_card: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkill {
pub id: String,
pub name: String,
pub description: String,
pub tags: Vec<String>,
#[serde(rename = "inputModes", skip_serializing_if = "Vec::is_empty")]
pub input_modes: Vec<String>,
#[serde(rename = "outputModes", skip_serializing_if = "Vec::is_empty")]
pub output_modes: Vec<String>,
}
fn content_ref_mode(reference: &ContentRef) -> String {
match reference {
ContentRef::ContentType(content_type) => match content_type {
ContentType::Raw => "application/octet-stream",
ContentType::Json => "application/json",
ContentType::Msgpack => "application/msgpack",
ContentType::Cbor => "application/cbor",
ContentType::Bson => "application/bson",
ContentType::Avro => "application/avro",
ContentType::Protobuf => "application/protobuf",
ContentType::Arrow => "application/vnd.apache.arrow.stream",
ContentType::Ref => "application/x-agdx-ref",
ContentType::Any => "*/*",
_ => "application/octet-stream",
}
.to_owned(),
ContentRef::SchemaId(id) => format!("application/x-agdx-schema;id={id}"),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskStatus {
#[serde(with = "task_state_json")]
pub state: TaskState,
}
mod task_state_json {
use super::TaskState;
use serde::de::Error;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(state: &TaskState, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(state)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<TaskState, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub status: TaskStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<Artifact>,
}
#[derive(Debug, Deserialize)]
pub struct JsonRpcRequest {
#[serde(default)]
pub id: JsonValue,
pub method: String,
#[serde(default)]
pub params: JsonValue,
}
#[derive(Debug, Serialize)]
pub struct JsonRpcResponse {
pub jsonrpc: &'static str,
pub id: JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
}
pub fn command_from_message_send(
record: agdx::RecordId,
conversation: agdx::ConversationId,
source: agdx::AgentId,
correlation: agdx::CorrelationId,
params_json: Vec<u8>,
) -> AgentEnvelope {
AgentEnvelope::command(record, conversation, source, correlation, params_json)
.with_operation(OPERATION_CHAT)
}
pub fn task_from_envelope(task_id: impl Into<String>, envelope: &AgentEnvelope) -> Task {
let state = envelope.task_state.unwrap_or(match envelope.kind {
agdx::AgentKind::Error => TaskState::Failed,
_ => TaskState::Completed,
});
let artifacts = if envelope.body.is_empty() {
Vec::new()
} else {
vec![Artifact {
text: String::from_utf8_lossy(&envelope.body).into_owned(),
}]
};
Task {
id: task_id.into(),
status: TaskStatus { state },
artifacts,
}
}
pub struct A2aBridge {
laser: Laser,
source: AgentId,
request_topic: AgentTopic<'static>,
reply_topic: AgentTopic<'static>,
capabilities: Vec<CapabilityDescriptor>,
hops: Vec<String>,
#[cfg(feature = "sign")]
signing_key: Option<crate::sign::SigningKey>,
}
impl A2aBridge {
pub fn new(
laser: Laser,
source: AgentId,
request_topic: AgentTopic<'static>,
reply_topic: AgentTopic<'static>,
) -> Self {
let hops = vec![source.as_str().to_owned()];
Self {
laser,
source,
request_topic,
reply_topic,
capabilities: Vec::new(),
hops,
#[cfg(feature = "sign")]
signing_key: None,
}
}
pub fn with_bridge_hops(mut self, previous: &[String]) -> Result<Self, LaserError> {
self.hops = enter_bridge(self.source.as_str(), previous)?;
Ok(self)
}
#[cfg(feature = "sign")]
#[must_use]
pub fn with_signing_key(mut self, key: crate::sign::SigningKey) -> Self {
self.signing_key = Some(key);
self
}
#[must_use]
pub fn with_capabilities(mut self, capabilities: Vec<CapabilityDescriptor>) -> Self {
self.capabilities = capabilities;
self
}
pub async fn submit(&self, params_json: Vec<u8>) -> Result<Task, LaserError> {
let task = ConversationId::new();
self.laser
.agdx(self.request_topic.clone(), self.source.clone(), task.into())
.command(correlation_of(task), params_json)
.with_operation(OPERATION_CHAT)
.with_metadata(METADATA_BRIDGE_HOPS, hops_metadata(&self.hops))
.content_type(ContentType::Json)
.send()
.await?;
Ok(Task {
id: task.to_string(),
status: TaskStatus {
state: TaskState::Submitted,
},
artifacts: Vec::new(),
})
}
pub async fn task(&self, id: &str) -> Result<Task, LaserError> {
let conversation = id
.parse::<ConversationId>()
.map_err(|_| LaserError::HandlerConfig(format!("invalid task id `{id}`")))?;
let correlation = correlation_of(conversation);
let answer = self
.laser
.find_agdx_reply(self.reply_topic.clone(), correlation)
.await?;
Ok(match answer {
Some(envelope) => task_from_envelope(id, &envelope),
None => Task {
id: id.to_owned(),
status: TaskStatus {
state: TaskState::Working,
},
artifacts: Vec::new(),
},
})
}
pub async fn cancel(&self, id: &str) -> Result<Task, LaserError> {
let conversation = id
.parse::<ConversationId>()
.map_err(|_| LaserError::HandlerConfig(format!("invalid task id `{id}`")))?;
let error = agdx::AgentErrorBody {
code: agdx::AgentErrorCode::Cancelled,
message: Some("canceled by the A2A client".to_owned()),
retryable: false,
detail: None,
};
let producer = self.laser.agdx(
self.reply_topic.clone(),
self.source.clone(),
conversation.into(),
);
let send = producer
.fail(correlation_of(conversation), &error)?
.with_task_state(TaskState::Canceled)
.with_metadata(METADATA_BRIDGE_HOPS, hops_metadata(&self.hops));
#[cfg(feature = "sign")]
let send = match &self.signing_key {
Some(key) => send.signed_by(key),
None => send,
};
send.send().await?;
Ok(Task {
id: id.to_owned(),
status: TaskStatus {
state: TaskState::Canceled,
},
artifacts: Vec::new(),
})
}
pub fn card(&self) -> AgentCard {
let skills = self
.capabilities
.iter()
.map(|capability| AgentSkill {
id: capability.skill_id.clone(),
name: capability.skill_id.clone(),
description: String::new(),
tags: Vec::new(),
input_modes: capability.input.iter().map(content_ref_mode).collect(),
output_modes: capability.output.iter().map(content_ref_mode).collect(),
})
.collect();
AgentCard {
name: self.source.as_str().to_owned(),
description: "LaserData AGDX bridge over the durable log".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
supported_interfaces: vec![AgentInterface {
url: "/".to_owned(),
protocol_binding: A2A_JSONRPC_BINDING.to_owned(),
protocol_version: A2A_PROTOCOL_VERSION.to_owned(),
}],
capabilities: AgentCardCapabilities {
streaming: true,
push_notifications: false,
state_transition_history: false,
extended_agent_card: false,
},
default_input_modes: vec!["text/plain".to_owned()],
default_output_modes: vec!["text/plain".to_owned()],
skills,
signatures: Vec::new(),
}
}
#[cfg(feature = "sign")]
pub fn signed_card(&self, key: &crate::sign::SigningKey) -> Result<AgentCard, LaserError> {
let mut card = self.card();
let value = serde_json::to_value(&card)
.map_err(|error| LaserError::Codec(format!("card to JSON: {error}")))?;
let signature = crate::sign::sign_card_value(key, &value)?;
card.signatures.push(signature);
Ok(card)
}
pub async fn handle_rpc(&self, request: JsonValue) -> JsonRpcResponse {
let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
match self.dispatch_rpc(&request).await {
Ok(result) => JsonRpcResponse {
jsonrpc: JSONRPC_VERSION,
id,
result: Some(result),
error: None,
},
Err(error) => {
tracing::warn!(error = %error, "A2A request failed");
JsonRpcResponse {
jsonrpc: JSONRPC_VERSION,
id,
result: None,
error: Some(JsonRpcError {
code: APP_ERROR_CODE,
message: crate::error::public_error_message(&error).to_owned(),
}),
}
}
}
}
async fn dispatch_rpc(&self, request: &JsonValue) -> Result<JsonValue, LaserError> {
if !request.is_object() {
return Err(LaserError::HandlerConfig(
"A2A JSON-RPC request must be an object".to_owned(),
));
}
let JsonRpcRequest { method, params, .. } = JsonRpcRequest::deserialize(request)
.map_err(|_| LaserError::HandlerConfig("A2A method must be a string".to_owned()))?;
let params = if params.is_null() {
JsonValue::Object(Default::default())
} else {
params
};
let task = match A2aMethod::from_str(&method) {
Ok(A2aMethod::MessageSend | A2aMethod::MessageStream) => {
let body = serde_json::to_vec(¶ms)
.map_err(|error| LaserError::Codec(error.to_string()))?;
self.submit(body).await?
}
Ok(operation @ (A2aMethod::TasksGet | A2aMethod::TasksCancel)) => {
let id = params
.as_object()
.and_then(|params| params.get("id"))
.and_then(JsonValue::as_str)
.ok_or_else(|| {
LaserError::HandlerConfig("A2A task id must be a string".to_owned())
})?;
if operation == A2aMethod::TasksGet {
self.task(id).await?
} else {
self.cancel(id).await?
}
}
Err(_) => {
return Err(LaserError::HandlerConfig(format!(
"unknown A2A method `{method}`"
)));
}
};
to_value(task).map_err(|error| LaserError::Codec(error.to_string()))
}
#[cfg(feature = "a2a-http")]
pub fn router(self: Arc<Self>) -> Router {
Router::new()
.route("/", post(handle_rpc))
.route(
"/.well-known/agent-card.json",
axum::routing::get(handle_card),
)
.with_state(self)
}
}
#[cfg(feature = "a2a-http")]
async fn handle_card(State(bridge): State<Arc<A2aBridge>>) -> axum::Json<AgentCard> {
axum::Json(bridge.card())
}
fn correlation_of(conversation: ConversationId) -> CorrelationId {
CorrelationId::from_u128(conversation.as_u128())
}
#[cfg(feature = "a2a-http")]
async fn handle_rpc(
State(bridge): State<Arc<A2aBridge>>,
axum::Json(request): axum::Json<JsonValue>,
) -> axum::Json<JsonRpcResponse> {
axum::Json(bridge.handle_rpc(request).await)
}
#[cfg(test)]
mod tests {
use super::*;
use laser_wire::framing::{decode_named, encode_named};
use serde_json::{from_str, to_string};
#[test]
fn given_task_states_when_serialized_then_should_use_the_kebab_case_wire_names() {
let status = TaskStatus {
state: TaskState::InputRequired,
};
assert_eq!(
to_string(&status).expect("status serializes"),
r#"{"state":"input-required"}"#
);
let back: TaskStatus = from_str(r#"{"state":"auth-required"}"#).expect("status parses");
assert_eq!(back.state, TaskState::AuthRequired);
assert!(from_str::<TaskStatus>(r#"{"state":"nope"}"#).is_err());
}
#[cfg(feature = "a2a-http")]
#[test]
fn given_a2a_methods_when_round_tripped_then_should_match_the_wire_names() {
assert_eq!(A2aMethod::MessageSend.to_string(), "SendMessage");
assert_eq!(
"GetTask".parse::<A2aMethod>().expect("the method parses"),
A2aMethod::TasksGet
);
assert!("tasks/get".parse::<A2aMethod>().is_err());
assert!("nope/now".parse::<A2aMethod>().is_err());
}
#[test]
fn given_a_message_send_when_mapped_then_the_foreign_payload_should_tunnel_byte_identical() {
let params = br#"{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]},"metadata":{"trace":"abc"}}"#;
let envelope = command_from_message_send(
agdx::RecordId::from_u128(1),
agdx::ConversationId::from_u128(2),
"source-agent".parse().expect("valid agent id"),
agdx::CorrelationId::from_u128(4),
params.to_vec(),
);
agdx::validate(&envelope).expect("the mapped command validates");
let payload = encode_named(&envelope).expect("encodes");
let back: AgentEnvelope = decode_named(&payload).expect("decodes");
assert_eq!(
back.body,
params.to_vec(),
"the tunneled remainder must round-trip byte-identical"
);
assert_eq!(back.correlation, envelope.correlation);
let reply = AgentEnvelope::response(
agdx::RecordId::from_u128(5),
agdx::ConversationId::from_u128(2),
"responder-agent".parse().expect("valid agent id"),
agdx::CorrelationId::from_u128(4),
b"plan ready".to_vec(),
)
.with_task_state(TaskState::Completed);
let task = task_from_envelope("t-1", &reply);
assert_eq!(
to_string(&task).expect("task serializes"),
r#"{"id":"t-1","status":{"state":"completed"},"artifacts":[{"text":"plan ready"}]}"#
);
let failure = AgentEnvelope::error(
agdx::RecordId::from_u128(7),
agdx::ConversationId::from_u128(2),
"responder-agent".parse().expect("valid agent id"),
agdx::CorrelationId::from_u128(4),
b"boom".to_vec(),
);
assert_eq!(
task_from_envelope("t-1", &failure).status.state,
TaskState::Failed
);
}
fn bridge() -> A2aBridge {
let laser = Laser::from_client(iggy::prelude::IggyClient::default());
A2aBridge::new(
laser,
"a2a-edge".parse().expect("valid agent id"),
AgentTopic::Commands,
AgentTopic::Responses,
)
}
#[tokio::test]
async fn given_malformed_rpc_requests_when_dispatched_then_should_echo_ids_and_return_public_errors()
{
for request in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!([7, "GetTask", {"id": "invalid"}]),
serde_json::json!({"id": 7}),
serde_json::json!({"id": 7, "method": "unknown"}),
serde_json::json!({"id": 7, "method": "GetTask", "params": {"id": 12}}),
serde_json::json!({"id": 7, "method": "CancelTask", "params": []}),
serde_json::json!({"id": 7, "method": "GetTask", "params": {"id": "invalid"}}),
] {
let expected = request.get("id").cloned().unwrap_or(JsonValue::Null);
let response = bridge().handle_rpc(request).await;
assert_eq!(response.id, expected);
assert_eq!(response.jsonrpc, "2.0");
assert!(response.result.is_none());
let error = response.error.expect("malformed request returns an error");
assert_eq!(error.code, -32000);
assert_eq!(error.message, "invalid request");
}
}
#[test]
fn given_a_new_bridge_when_built_then_should_stamp_its_own_id_as_the_hop_path() {
assert_eq!(bridge().hops, ["a2a-edge"]);
}
#[test]
fn given_an_upstream_hop_path_when_continued_then_should_append_this_bridge() {
let bridge = bridge()
.with_bridge_hops(&["mcp-edge".to_owned()])
.expect("no loop");
assert_eq!(bridge.hops, ["mcp-edge", "a2a-edge"]);
assert!(matches!(
bridge.with_bridge_hops(&["a2a-edge".to_owned()]),
Err(LaserError::Invalid(_))
));
}
#[test]
fn given_a_jsonrpc_message_send_when_parsed_then_should_expose_the_text_part() {
let request: JsonRpcRequest = from_str(
r#"{"jsonrpc":"2.0","id":1,"method":"SendMessage",
"params":{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]}}}"#,
)
.expect("the request parses");
assert_eq!(request.method, "SendMessage");
assert_eq!(
request
.params
.pointer("/message/parts/0/text")
.and_then(JsonValue::as_str),
Some("hi")
);
}
}