use crate::agent::Laser;
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,
OPERATION_CHAT,
};
use laser_wire::content::ContentType;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
#[cfg(feature = "a2a-http")]
use serde_json::to_value;
#[cfg(feature = "a2a-http")]
use std::str::FromStr;
#[cfg(feature = "a2a-http")]
use std::sync::Arc;
#[cfg(feature = "a2a-http")]
use strum::{Display, EnumString};
pub use laser_wire::agent::TaskState;
pub fn enter_bridge(bridge: &str, previous: &[String]) -> Result<Vec<String>, LaserError> {
if bridge.is_empty() {
return Err(LaserError::Invalid(
"bridge id must not be empty".to_owned(),
));
}
if previous.iter().any(|hop| hop == bridge) {
return Err(LaserError::Invalid(format!(
"bridge loop detected at `{bridge}`"
)));
}
let mut hops = previous.to_vec();
hops.push(bridge.to_owned());
Ok(hops)
}
#[cfg(feature = "a2a-http")]
const JSONRPC_VERSION: &str = "2.0";
#[cfg(feature = "a2a-http")]
const APP_ERROR_CODE: i32 = -32000;
#[cfg(feature = "a2a-http")]
#[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 {
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>,
#[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 {
Self {
laser,
source,
request_topic,
reply_topic,
capabilities: Vec::new(),
#[cfg(feature = "sign")]
signing_key: None,
}
}
#[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)
.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);
#[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)
}
#[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<JsonRpcRequest>,
) -> axum::Json<JsonRpcResponse> {
let outcome = match A2aMethod::from_str(&request.method) {
Ok(A2aMethod::MessageSend | A2aMethod::MessageStream) => {
match serde_json::to_vec(&request.params) {
Ok(params_json) => bridge.submit(params_json).await,
Err(error) => Err(LaserError::Codec(format!(
"message params are not serializable: {error}"
))),
}
}
Ok(A2aMethod::TasksGet) => {
let id = request
.params
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or_default();
bridge.task(id).await
}
Ok(A2aMethod::TasksCancel) => {
let id = request
.params
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or_default();
bridge.cancel(id).await
}
Err(_) => Err(LaserError::HandlerConfig(format!(
"unknown A2A method `{}`",
request.method
))),
};
let response = match outcome {
Ok(task) => match to_value(task) {
Ok(value) => JsonRpcResponse {
jsonrpc: JSONRPC_VERSION,
id: request.id,
result: Some(value),
error: None,
},
Err(error) => {
tracing::warn!(error = %error, "A2A reply serialization failed");
JsonRpcResponse {
jsonrpc: JSONRPC_VERSION,
id: request.id,
result: None,
error: Some(JsonRpcError {
code: APP_ERROR_CODE,
message: "internal error".to_owned(),
}),
}
}
},
Err(error) => {
tracing::warn!(error = %error, method = %request.method, "A2A request failed");
JsonRpcResponse {
jsonrpc: JSONRPC_VERSION,
id: request.id,
result: None,
error: Some(JsonRpcError {
code: APP_ERROR_CODE,
message: crate::error::public_error_message(&error).to_owned(),
}),
}
}
};
axum::Json(response)
}
#[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
);
}
#[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")
);
}
}