a2a-rs 0.10.0

Rust implementation of the Agent-to-Agent (A2A) Protocol
Documentation
//! Shared JSON-RPC 2.0 wire vocabulary for the A2A protocol.
//!
//! These method names, error codes, request/response envelopes, and the
//! `A2AError` ⇄ JSON-RPC error mappings are the contract both the server adapter
//! ([`JsonRpcAdapter`](super::jsonrpc::JsonRpcAdapter)) and the client adapter
//! ([`JsonRpcClient`](super::jsonrpc_client::JsonRpcClient)) must agree on
//! byte-for-byte. Keeping them in one module guarantees the two directions never
//! drift.
//!
//! Both envelopes derive `Serialize + Deserialize`: the server deserializes
//! requests / serializes responses, and the client does the inverse.

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::domain::{A2AError, ErrorDetail, ErrorInfo};

/// A2A JSON-RPC method names (PascalCase, per spec).
pub mod methods {
    pub const SEND_MESSAGE: &str = "SendMessage";
    pub const SEND_STREAMING_MESSAGE: &str = "SendStreamingMessage";
    pub const GET_TASK: &str = "GetTask";
    pub const LIST_TASKS: &str = "ListTasks";
    pub const CANCEL_TASK: &str = "CancelTask";
    pub const SUBSCRIBE_TO_TASK: &str = "SubscribeToTask";
    pub const CREATE_PUSH_CONFIG: &str = "CreateTaskPushNotificationConfig";
    pub const GET_PUSH_CONFIG: &str = "GetTaskPushNotificationConfig";
    pub const LIST_PUSH_CONFIGS: &str = "ListTaskPushNotificationConfigs";
    pub const DELETE_PUSH_CONFIG: &str = "DeleteTaskPushNotificationConfig";
    pub const GET_EXTENDED_AGENT_CARD: &str = "GetExtendedAgentCard";

    /// Streaming methods respond with SSE rather than a single response.
    pub fn is_streaming(method: &str) -> bool {
        matches!(method, SEND_STREAMING_MESSAGE | SUBSCRIBE_TO_TASK)
    }
}

/// Standard + A2A-specific JSON-RPC error codes (mirrors the official SDK).
///
/// These are the domain's own constants, re-exported under the names this
/// module has always used: the number a variant maps to is decided once, in
/// `domain::error`, and a table here that spelled one differently would be the
/// drift this module exists to prevent.
pub mod error_code {
    pub use crate::domain::error::{
        AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED as EXTENDED_CARD_NOT_CONFIGURED,
        CONTENT_TYPE_NOT_SUPPORTED, CONTEXT_ACCESS_DENIED, DATABASE_ERROR, INTERNAL_ERROR,
        INVALID_AGENT_RESPONSE, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR,
        PUSH_NOTIFICATION_NOT_SUPPORTED, TASK_NOT_CANCELABLE, TASK_NOT_FOUND,
        UNSUPPORTED_OPERATION, VERSION_CONFLICT,
    };
}

/// JSON-RPC request envelope (server deserializes; client serializes).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
    pub jsonrpc: String,
    #[serde(default)]
    pub id: JsonRpcId,
    pub method: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub params: Option<Value>,
}

/// JSON-RPC response envelope (server serializes; client deserializes).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
    pub jsonrpc: String,
    #[serde(default)]
    pub id: JsonRpcId,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<JsonRpcError>,
}

impl JsonRpcResponse {
    pub fn ok(id: JsonRpcId, result: Value) -> Self {
        Self {
            jsonrpc: "2.0".to_string(),
            id,
            result: Some(result),
            error: None,
        }
    }

    pub fn err(id: JsonRpcId, error: JsonRpcError) -> Self {
        Self {
            jsonrpc: "2.0".to_string(),
            id,
            result: None,
            error: Some(error),
        }
    }
}

/// JSON-RPC request id — preserves the wire type (string, number, or null).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JsonRpcId {
    Str(String),
    Num(i64),
    #[default]
    Null,
}

/// JSON-RPC error object. `data` carries typed A2A error details when available.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
    pub code: i32,
    pub message: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub data: Option<Value>,
}

/// The JSON-RPC error code for a domain [`A2AError`].
///
/// This and [`jsonrpc_to_a2a`] are the one table both bindings read: the
/// JSON-RPC adapters put the code in `error.code`, and the ConnectRPC adapters
/// carry the same object as an error detail (see `connect_wire`). A variant
/// added to `A2AError` without an arm here goes on the wire as `-32603` — the
/// exhaustive match is what makes that a compile error rather than a
/// mislabelled refusal.
pub fn a2a_error_code(err: &A2AError) -> i32 {
    use error_code::*;
    match err {
        A2AError::JsonRpc { code, .. } => *code,
        A2AError::JsonParse(_) => PARSE_ERROR,
        A2AError::InvalidRequest(_) => INVALID_REQUEST,
        A2AError::MethodNotFound(_) => METHOD_NOT_FOUND,
        A2AError::InvalidParams(_) | A2AError::ValidationError { .. } => INVALID_PARAMS,
        A2AError::TaskNotFound(_) => TASK_NOT_FOUND,
        A2AError::TaskNotCancelable(_) => TASK_NOT_CANCELABLE,
        A2AError::PushNotificationNotSupported => PUSH_NOTIFICATION_NOT_SUPPORTED,
        A2AError::UnsupportedOperation(_) => UNSUPPORTED_OPERATION,
        A2AError::ContentTypeNotSupported(_) => CONTENT_TYPE_NOT_SUPPORTED,
        A2AError::InvalidAgentResponse(_) => INVALID_AGENT_RESPONSE,
        A2AError::AuthenticatedExtendedCardNotConfigured => EXTENDED_CARD_NOT_CONFIGURED,
        A2AError::VersionConflict { .. } => VERSION_CONFLICT,
        A2AError::DatabaseError(_) => DATABASE_ERROR,
        A2AError::ContextAccessDenied { .. } => CONTEXT_ACCESS_DENIED,
        A2AError::Internal(_) | A2AError::Io(_) => INTERNAL_ERROR,
    }
}

/// Map a domain [`A2AError`] onto a JSON-RPC error object.
///
/// This is the JSON-RPC analogue of `connectrpc::map_err`. The `data` array
/// carries the error's [typed details](A2AError::error_details): a Google-RPC
/// `BadRequest` for validation failures, plus an `ErrorInfo` reason code on every
/// error so clients can branch on a stable machine code rather than the message
/// string. [`jsonrpc_to_a2a`] reverses this.
pub fn a2a_to_jsonrpc(err: &A2AError) -> JsonRpcError {
    let message = match err {
        A2AError::ValidationError { field, message } => format!("{field}: {message}"),
        other => other.to_string(),
    };
    let details = err.error_details();
    let data = (!details.is_empty()).then(|| serde_json::json!(details));
    JsonRpcError {
        code: a2a_error_code(err),
        message,
        data,
    }
}

/// Map a JSON-RPC error object back onto a domain [`A2AError`] — the inverse of
/// [`a2a_to_jsonrpc`], used by the client to reconstruct typed errors.
///
/// Every spec code comes back as the variant that produced it, so a caller can
/// `match` on the refusal it actually got rather than on a number. The two
/// variants with structured payloads — [`A2AError::VersionConflict`] and
/// [`A2AError::ContextAccessDenied`] — are rebuilt from their `ErrorInfo`
/// metadata when it is present. A code this table does not know (a foreign
/// server's own range, or a spec code newer than this crate) stays
/// [`A2AError::JsonRpc`] with the number intact.
pub fn jsonrpc_to_a2a(err: &JsonRpcError) -> A2AError {
    use error_code::*;
    let message = || err.message.clone();
    match err.code {
        PARSE_ERROR | INVALID_REQUEST => A2AError::InvalidRequest(message()),
        INVALID_PARAMS => A2AError::InvalidParams(message()),
        METHOD_NOT_FOUND => A2AError::MethodNotFound(message()),
        INTERNAL_ERROR => A2AError::Internal(message()),
        TASK_NOT_FOUND => A2AError::TaskNotFound(message()),
        TASK_NOT_CANCELABLE => A2AError::TaskNotCancelable(message()),
        PUSH_NOTIFICATION_NOT_SUPPORTED => A2AError::PushNotificationNotSupported,
        UNSUPPORTED_OPERATION => A2AError::UnsupportedOperation(message()),
        CONTENT_TYPE_NOT_SUPPORTED => A2AError::ContentTypeNotSupported(message()),
        INVALID_AGENT_RESPONSE => A2AError::InvalidAgentResponse(message()),
        EXTENDED_CARD_NOT_CONFIGURED => A2AError::AuthenticatedExtendedCardNotConfigured,
        DATABASE_ERROR => A2AError::DatabaseError(message()),
        VERSION_CONFLICT => version_conflict_from_data(err)
            .unwrap_or_else(|| A2AError::Internal(err.message.clone())),
        CONTEXT_ACCESS_DENIED => A2AError::ContextAccessDenied {
            context_id: error_info_metadata(err, "context_id").unwrap_or_default(),
        },
        code => A2AError::JsonRpc {
            code,
            message: err.message.clone(),
            data: err.data.clone(),
        },
    }
}

/// The `ErrorInfo` detail in a wire error's `data` array, if it carries one.
fn error_info(err: &JsonRpcError) -> Option<ErrorInfo> {
    let details: Vec<ErrorDetail> = serde_json::from_value(err.data.clone()?).ok()?;
    details.into_iter().find_map(|d| match d {
        ErrorDetail::ErrorInfo(info) => Some(info),
        _ => None,
    })
}

/// One `ErrorInfo` metadata value from a wire error, if present.
fn error_info_metadata(err: &JsonRpcError, key: &str) -> Option<String> {
    error_info(err)?.metadata.get(key).cloned()
}

/// Reconstruct a [`A2AError::VersionConflict`] from the `ErrorInfo` metadata in a
/// wire error's `data` array, if it carries the expected/actual versions.
fn version_conflict_from_data(err: &JsonRpcError) -> Option<A2AError> {
    let ErrorInfo { metadata, .. } = error_info(err)?;
    Some(A2AError::VersionConflict {
        id: metadata.get("task_id").cloned().unwrap_or_default(),
        expected: metadata.get("expected").and_then(|s| s.parse().ok())?,
        actual: metadata.get("actual").and_then(|s| s.parse().ok())?,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validation_error_surfaces_bad_request_details() {
        let err = A2AError::ValidationError {
            field: "history_length".to_string(),
            message: "too large".to_string(),
        };
        let wire = a2a_to_jsonrpc(&err);
        assert_eq!(wire.code, error_code::INVALID_PARAMS);
        let data = wire.data.expect("validation errors carry data");
        // First detail is a Google-RPC BadRequest naming the field.
        assert_eq!(
            data[0]["@type"],
            "type.googleapis.com/google.rpc.BadRequest"
        );
        assert_eq!(data[0]["fieldViolations"][0]["field"], "history_length");
        // Second detail is the stable reason code.
        assert_eq!(data[1]["reason"], "VALIDATION_ERROR");
    }

    #[test]
    fn version_conflict_round_trips_through_the_wire() {
        let err = A2AError::VersionConflict {
            id: "task-42".to_string(),
            expected: 3,
            actual: 5,
        };
        let wire = a2a_to_jsonrpc(&err);
        assert_eq!(wire.code, error_code::VERSION_CONFLICT);
        match jsonrpc_to_a2a(&wire) {
            A2AError::VersionConflict {
                id,
                expected,
                actual,
            } => {
                assert_eq!(id, "task-42");
                assert_eq!(expected, 3);
                assert_eq!(actual, 5);
            }
            other => panic!("expected VersionConflict, got {other:?}"),
        }
    }

    #[test]
    fn every_error_carries_a_reason_code() {
        let wire = a2a_to_jsonrpc(&A2AError::TaskNotFound("x".to_string()));
        let data = wire.data.expect("errors carry an ErrorInfo reason");
        assert_eq!(data[0]["reason"], "TASK_NOT_FOUND");
        assert_eq!(data[0]["domain"], "a2a-rs");
    }
}