Skip to main content

protocol_droid/application/a2a/
handler.rs

1//! The A2A request handler trait — the consumer's
2//! extension point for A2A JSON-RPC methods.
3//!
4//! # What this is
5//!
6//! A consumer who runs an A2A server implements
7//! [`A2aRequestHandler`] to define what each A2A
8//! method does. The serving adapter
9//! ([`serve_jsonrpc`](crate::adapters::primary::a2a::jsonrpc_serve::serve_jsonrpc))
10//! decodes the JSON-RPC envelope, looks up the
11//! method name in the [`A2aMethodTable`](crate::adapters::secondary::a2a::A2aMethodTable),
12//! and calls the handler with the matched method,
13//! the raw params, and the per-request context.
14//! The handler returns the raw result (or an
15//! [`A2aError`]); the serving adapter serialises
16//! the result into a JSON-RPC response.
17//!
18//! # Why a single dispatch method
19//!
20//! The A2A v1.0 spec defines 11 methods. A trait
21//! with 11 typed methods is exhaustive (a handler
22//! must implement all 11) and locks the wire
23//! shape of every method at the type level. A
24//! single dispatch method lets a consumer implement
25//! only the methods they care about and return
26//! [`A2aError::MethodNotImplemented`] for the rest,
27//! which is what the 0.3.x → 1.0 migration story
28//! looks like in practice: a server starts with
29//! the two or three methods it needs, and adds
30//! the others as consumers ask.
31//!
32//! The trade-off is no per-method compile-time
33//! type safety on the params. The params arrive
34//! as [`serde_json::Value`]; the consumer is
35//! responsible for decoding them. A future
36//! per-method typed wrapper is a separate
37//! commit, not a replacement for this trait.
38//!
39//! # Why this is object-safe
40//!
41//! The serving adapter is generic over a concrete
42//! handler in the call site, but a consumer who
43//! wants to swap handlers at runtime holds an
44//! `Arc<dyn A2aRequestHandler>`. Object safety
45//! keeps both options open. The trade-off is the
46//! boxed future on the per-call path: the JSON-RPC
47//! dispatch itself is the project's hot path, not
48//! the handler invocation. The
49//! `Pin<Box<dyn Future + Send>>` signature
50//! matches [`RequestHandler`](crate::adapters::primary::transport::http::router::RequestHandler)
51//! in spirit but is a single concrete shape,
52//! not RPITIT, because `async fn` in a trait
53//! does not make the resulting future
54//! object-safe in 1.98.
55
56use std::pin::Pin;
57
58use futures_core::Future;
59
60use crate::adapters::secondary::a2a::A2aMethod;
61use crate::domain::a2a::version::A2aVersion;
62
63/// An A2A-specific error.
64///
65/// The variants are the JSON-RPC error codes the
66/// A2A spec names. The serving adapter maps each
67/// variant to a code and message on the wire; the
68/// consumer never has to know the codes.
69///
70/// # JSON-RPC error codes
71///
72/// - `-32601`: `MethodNotFound`
73/// - `-32602`: `InvalidParams`
74/// - `-32603`: `InternalError`
75/// - `-32000`: `ContentTypeNotSupported`
76/// - `-32001`: `AuthenticationRequired`
77/// - `-32002`: `AuthenticationFailed`
78///
79/// The negative numbers are the JSON-RPC
80/// standard's reserved range for "implementation-
81/// defined server errors"; A2A v1.0 §5.3 fixes
82/// these specific codes. The standard
83/// JSON-RPC error codes (`-32700` `ParseError`,
84/// `-32600` `Invalid Request`, `-32601`
85/// `Method Not Found`, `-32602` `Invalid
86/// Params`, `-32603` `Internal Error`) are
87/// reserved by JSON-RPC itself; the A2A spec
88/// assigns them and extends the
89/// implementation-defined range to `-32000`
90/// through `-32002`.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum A2aError {
93    /// The method name is not in the A2A
94    /// vocabulary. Maps to JSON-RPC `-32601`.
95    MethodNotFound,
96
97    /// The method is known to A2A but the
98    /// consumer's handler does not implement it.
99    /// Maps to JSON-RPC `-32601` (the same code
100    /// as `MethodNotFound`; the distinction is
101    /// for the consumer's logs).
102    MethodNotImplemented {
103        /// The method the consumer did not
104        /// implement.
105        method: A2aMethod,
106    },
107
108    /// The params could not be decoded into the
109    /// shape the handler expects. Maps to JSON-RPC
110    /// `-32602`.
111    InvalidParams {
112        /// Why the params are wrong. The serving
113        /// adapter surfaces this on the wire as
114        /// the JSON-RPC `data` field.
115        reason: String,
116    },
117
118    /// The handler failed in a way that is not
119    /// the caller's fault. Maps to JSON-RPC
120    /// `-32603`.
121    InternalError {
122        /// The error message. The serving adapter
123        /// surfaces this on the wire as the
124        /// JSON-RPC `message` field.
125        message: String,
126    },
127
128    /// The request requires authentication and
129    /// the request did not carry credentials.
130    /// Maps to JSON-RPC `-32001`.
131    AuthenticationRequired,
132
133    /// The request carried credentials and they
134    /// were not accepted. Maps to JSON-RPC
135    /// `-32002`.
136    AuthenticationFailed {
137        /// Why the credentials were rejected.
138        reason: String,
139    },
140
141    /// The request's `Content-Type` is not
142    /// `application/json`. Maps to JSON-RPC
143    /// `-32000`.
144    ContentTypeNotSupported {
145        /// The `Content-Type` the request carried.
146        received: String,
147    },
148}
149
150impl A2aError {
151    /// The JSON-RPC error code for this error.
152    ///
153    /// The codes are the A2A v1.0 spec's
154    /// implementation-defined server-error range.
155    /// The standard JSON-RPC error codes
156    /// (`-32700` `ParseError`, `-32600` `Invalid`
157    /// `Request`, `-32601` `Method` `Not` `Found`,
158    /// `-32602` `Invalid` `Params`, `-32603`
159    /// `Internal` `Error`) are reserved by
160    /// JSON-RPC itself; the A2A spec assigns them
161    /// and extends the implementation-defined
162    /// range to `-32000` through `-32002`.
163    #[must_use]
164    pub const fn code(&self) -> i32 {
165        match self {
166            Self::MethodNotFound | Self::MethodNotImplemented { .. } => -32601,
167            Self::InvalidParams { .. } => -32602,
168            Self::InternalError { .. } => -32603,
169            Self::ContentTypeNotSupported { .. } => -32000,
170            Self::AuthenticationRequired => -32001,
171            Self::AuthenticationFailed { .. } => -32002,
172        }
173    }
174
175    /// The JSON-RPC error message for this error.
176    ///
177    /// A short, fixed string per the A2A spec;
178    /// detailed context goes in the `data` field
179    /// (the serving adapter's responsibility, not
180    /// this one).
181    #[must_use]
182    pub const fn message(&self) -> &'static str {
183        match self {
184            Self::MethodNotFound => "Method not found",
185            Self::MethodNotImplemented { .. } => "Method not implemented",
186            Self::InvalidParams { .. } => "Invalid params",
187            Self::InternalError { .. } => "Internal error",
188            Self::ContentTypeNotSupported { .. } => "Content-Type not supported",
189            Self::AuthenticationRequired => "Authentication required",
190            Self::AuthenticationFailed { .. } => "Authentication failed",
191        }
192    }
193}
194
195/// The per-request context the A2A handler
196/// receives.
197///
198/// Carries the negotiated protocol version (so
199/// the handler can decode per-revision params)
200/// and the auth context (so a handler that needs
201/// to check identity has it without re-parsing
202/// headers). Future fields land here as the
203/// protocol grows: the request id, the request
204/// deadline, the tenant id from the URL.
205#[derive(Debug, Clone)]
206pub struct A2aRequestContext {
207    /// The negotiated A2A protocol version. The
208    /// handler is responsible for decoding params
209    /// against this version's wire shape.
210    pub protocol_version: A2aVersion,
211}
212
213/// The consumer's extension point for A2A methods.
214///
215/// The serving adapter calls [`dispatch`](Self::dispatch)
216/// once per JSON-RPC request. The handler receives
217/// the method (already validated against the
218/// A2A vocabulary) and the raw `serde_json::Value`
219/// params. The handler returns the raw result
220/// value, or an [`A2aError`].
221///
222/// # Object safety
223///
224/// The trait is object-safe. A consumer who wants
225/// to swap handlers at runtime holds an
226/// `Arc<dyn A2aRequestHandler>`; the serving
227/// adapter accepts either an `Arc<dyn>` or a
228/// concrete `H: A2aRequestHandler`.
229///
230/// # Default impls
231///
232/// None of the methods have a default impl. A
233/// consumer who wants to leave a method
234/// unimplemented can return
235/// [`A2aError::MethodNotImplemented`] from
236/// `dispatch`; the serving adapter maps that to
237/// JSON-RPC `-32601` and the caller's client
238/// knows the method is not supported.
239pub trait A2aRequestHandler: Send + Sync + 'static {
240    /// Handles a routed A2A method.
241    ///
242    /// The serving adapter guarantees:
243    /// - `method` was looked up in the A2A
244    ///   vocabulary (so it is one of the 11
245    ///   methods, not a string);
246    /// - `params` is the raw JSON-RPC params
247    ///   object, or `None` if the request had no
248    ///   params.
249    ///
250    /// The handler is responsible for:
251    /// - Decoding `params` into the per-method
252    ///   type;
253    /// - Running the method's logic;
254    /// - Returning either the raw result value
255    ///   (which the serving adapter puts in the
256    ///   `result` field) or an [`A2aError`]
257    ///   (which becomes the `error` field).
258    ///
259    /// # Errors
260    ///
261    /// Any [`A2aError`]. The serving adapter
262    /// answers with the right JSON-RPC code;
263    /// the handler never has to know the code.
264    ///
265    /// # Example
266    ///
267    /// A minimal handler that implements only
268    /// `SendMessage` and returns
269    /// `A2aError::MethodNotImplemented` for the
270    /// other ten methods. The full reference
271    /// impl is in the `a2a_server` example.
272    ///
273    /// ```no_run
274    /// use std::pin::Pin;
275    /// use std::sync::Arc;
276    ///
277    /// use futures_core::Future;
278    /// use serde_json::{json, Value};
279    ///
280    /// use protocol_droid::adapters::secondary::a2a::A2aMethod;
281    ///     use protocol_droid::application::a2a::{
282    ///     A2aError, A2aRequestContext, A2aRequestHandler,
283    /// };
284    ///
285    /// struct EchoAgent;
286    ///
287    /// impl A2aRequestHandler for EchoAgent {
288    ///     fn dispatch(
289    ///         &self,
290    ///         method: A2aMethod,
291    ///         params: Option<Value>,
292    ///         _context: &A2aRequestContext,
293    ///     ) -> Pin<
294    ///         Box<dyn Future<Output = Result<Value, A2aError>> + Send + '_>,
295    ///     > {
296    ///         Box::pin(async move {
297    ///             match method {
298    ///                 A2aMethod::SendMessage => {
299    ///                     // Decode the params, run the
300    ///                     // agent's logic, return the
301    ///                     // result as JSON.
302    ///                     Ok(json!({"id": "t-1", "status":
303    ///                         {"state": "completed"}}))
304    ///                 }
305    ///                 other => Err(A2aError::MethodNotImplemented {
306    ///                     method: other,
307    ///                 }),
308    ///             }
309    ///         })
310    ///     }
311    /// }
312    ///
313    /// let _handler: Arc<dyn A2aRequestHandler> = Arc::new(EchoAgent);
314    /// ```
315    fn dispatch(
316        &self,
317        method: A2aMethod,
318        params: Option<serde_json::Value>,
319        context: &A2aRequestContext,
320    ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, A2aError>> + Send + '_>>;
321}
322
323#[cfg(test)]
324mod tests {
325    use super::A2aError;
326    use crate::adapters::secondary::a2a::A2aMethod;
327
328    #[test]
329    fn method_not_found_is_minus_32601() {
330        assert_eq!(A2aError::MethodNotFound.code(), -32601);
331    }
332
333    #[test]
334    fn method_not_implemented_is_minus_32601() {
335        // Same code as `MethodNotFound`; the
336        // distinction is in the message.
337        let err = A2aError::MethodNotImplemented {
338            method: A2aMethod::SendMessage,
339        };
340        assert_eq!(err.code(), -32601);
341        assert_eq!(err.message(), "Method not implemented");
342    }
343
344    #[test]
345    fn invalid_params_is_minus_32602() {
346        let err = A2aError::InvalidParams {
347            reason: "expected an object".to_owned(),
348        };
349        assert_eq!(err.code(), -32602);
350    }
351
352    #[test]
353    fn internal_error_is_minus_32603() {
354        let err = A2aError::InternalError {
355            message: "database is down".to_owned(),
356        };
357        assert_eq!(err.code(), -32603);
358    }
359
360    #[test]
361    fn content_type_not_supported_is_minus_32000() {
362        let err = A2aError::ContentTypeNotSupported {
363            received: "text/plain".to_owned(),
364        };
365        assert_eq!(err.code(), -32000);
366    }
367
368    #[test]
369    fn authentication_required_is_minus_32001() {
370        assert_eq!(A2aError::AuthenticationRequired.code(), -32001);
371    }
372
373    #[test]
374    fn authentication_failed_is_minus_32002() {
375        let err = A2aError::AuthenticationFailed {
376            reason: "token expired".to_owned(),
377        };
378        assert_eq!(err.code(), -32002);
379    }
380
381    #[test]
382    fn messages_are_stable_strings() {
383        // The spec fixes the message text;
384        // changing any of these is a wire break.
385        assert_eq!(A2aError::MethodNotFound.message(), "Method not found");
386        assert_eq!(
387            A2aError::InternalError {
388                message: "x".to_owned()
389            }
390            .message(),
391            "Internal error"
392        );
393        assert_eq!(
394            A2aError::ContentTypeNotSupported {
395                received: "x".to_owned()
396            }
397            .message(),
398            "Content-Type not supported"
399        );
400        assert_eq!(
401            A2aError::AuthenticationRequired.message(),
402            "Authentication required"
403        );
404    }
405}