Skip to main content

protocol_droid/application/acp/
dispatch.rs

1//! The ACP request handler trait — the consumer's
2//! extension point for ACP JSON-RPC methods.
3//!
4//! # What this is
5//!
6//! A consumer who runs an ACP agent implements
7//! [`AcpRequestHandler`] to define what each ACP
8//! method does. The serving adapter
9//! (the agent's `AcpAgentPeer` paired with a
10//! matching JSON-RPC dispatcher) decodes the
11//! JSON-RPC envelope, looks up the method name
12//! in the [`AgentMethod`](crate::domain::acp::method::AgentMethod) table, and calls the
13//! handler with the matched method, the raw
14//! params, and the per-request context. The
15//! handler returns the raw result (or an
16//! [`AcpError`]); the serving adapter serialises
17//! the result into a JSON-RPC response.
18//!
19//! # Why a single dispatch method
20//!
21//! The ACP v1.25.0 spec defines 13 agent-side
22//! methods. A trait with 13 typed methods is
23//! exhaustive (a handler must implement all
24//! 13) and locks the wire shape of every method
25//! at the type level. A single dispatch method
26//! lets a consumer implement only the methods
27//! they care about and return
28//! [`AcpError::MethodNotImplemented`] for the
29//! rest, which is what the early-adopter story
30//! looks like in practice: an agent starts with
31//! the three or four methods it needs (`initialize`,
32//! `session/new`, `session/prompt`, `session/cancel`),
33//! and adds the others as clients ask.
34//!
35//! The trade-off is no per-method compile-time
36//! type safety on the params. The params arrive
37//! as [`serde_json::Value`]; the consumer is
38//! responsible for decoding them. A future
39//! per-method typed wrapper is a separate
40//! commit (the [`super::typed_dispatch`] module),
41//! not a replacement for this trait.
42//!
43//! # Why this is object-safe
44//!
45//! The serving adapter is generic over a concrete
46//! handler in the call site, but a consumer who
47//! wants to swap handlers at runtime holds an
48//! `Arc<dyn AcpRequestHandler>`. Object safety
49//! keeps both options open. The trade-off is the
50//! boxed future on the per-call path: the JSON-RPC
51//! dispatch itself is the project's hot path, not
52//! the handler invocation. The
53//! `Pin<Box<dyn Future + Send>>` signature
54//! matches [`A2aRequestHandler`](super::super::a2a::A2aRequestHandler)
55//! in spirit but is a single concrete shape,
56//! not RPITIT, because `async fn` in a trait
57//! does not make the resulting future
58//! object-safe in 1.98.
59
60use core::fmt;
61use core::pin::Pin;
62
63use futures_core::Future;
64use serde_json::Value;
65
66use crate::domain::acp::method::AgentMethod;
67
68/// An ACP-specific error.
69///
70/// The variants are the JSON-RPC error codes the
71/// ACP spec names. The serving adapter maps each
72/// variant to a code and message on the wire; the
73/// consumer never has to know the codes.
74///
75/// # JSON-RPC error codes
76///
77/// - `-32601`: `MethodNotFound` / `MethodNotImplemented`
78/// - `-32602`: `InvalidParams`
79/// - `-32603`: `InternalError`
80///
81/// The negative numbers are the JSON-RPC
82/// standard's reserved range. ACP v1.25.0
83/// does not extend the implementation-defined
84/// `-32000..=-32099` range, so this enum has
85/// the same surface as the JSON-RPC standard's
86/// own.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum AcpError {
89    /// The method name is not in the ACP
90    /// vocabulary. Maps to JSON-RPC `-32601`.
91    MethodNotFound,
92
93    /// The method is known to ACP but the
94    /// consumer's handler does not implement it.
95    /// Maps to JSON-RPC `-32601` (the same code
96    /// as `MethodNotFound`; the distinction is
97    /// for the consumer's logs).
98    MethodNotImplemented {
99        /// The method the consumer did not
100        /// implement.
101        method: AgentMethod,
102    },
103
104    /// The params could not be decoded into the
105    /// shape the handler expects. Maps to JSON-RPC
106    /// `-32602`.
107    InvalidParams {
108        /// Why the params are wrong. The serving
109        /// adapter surfaces this on the wire as
110        /// the JSON-RPC `data` field.
111        reason: String,
112    },
113
114    /// The handler failed in a way that is not
115    /// the caller's fault. Maps to JSON-RPC
116    /// `-32603`.
117    InternalError {
118        /// A short, human-readable description of
119        /// what went wrong. Goes on the wire as
120        /// the JSON-RPC `message` field.
121        message: String,
122    },
123}
124
125impl AcpError {
126    /// The JSON-RPC error code for this error.
127    ///
128    /// The codes are the JSON-RPC standard's
129    /// reserved range; ACP v1.25.0 does not
130    /// extend the implementation-defined
131    /// `-32000..=-32099` range.
132    #[must_use]
133    pub const fn code(&self) -> i32 {
134        match self {
135            Self::MethodNotFound | Self::MethodNotImplemented { .. } => -32601,
136            Self::InvalidParams { .. } => -32602,
137            Self::InternalError { .. } => -32603,
138        }
139    }
140
141    /// The JSON-RPC error message for this error.
142    ///
143    /// A short, fixed string per the JSON-RPC
144    /// standard; detailed context goes in the
145    /// `data` field (the serving adapter's
146    /// responsibility, not this one).
147    #[must_use]
148    pub const fn message(&self) -> &'static str {
149        match self {
150            Self::MethodNotFound => "Method not found",
151            Self::MethodNotImplemented { .. } => "Method not implemented",
152            Self::InvalidParams { .. } => "Invalid params",
153            Self::InternalError { .. } => "Internal error",
154        }
155    }
156}
157
158impl fmt::Display for AcpError {
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        match self {
161            Self::MethodNotFound => formatter.write_str("method not found"),
162            Self::MethodNotImplemented { method } => {
163                // The wire form of the method is
164                // what the consumer's log line
165                // wants — `session/prompt`, not
166                // `SessionPrompt`. `AgentMethod`
167                // does not implement `Display` (the
168                // canonical wire form is the
169                // `as_wire_name` method), so we
170                // call it here.
171                write!(
172                    formatter,
173                    "method not implemented: {}",
174                    method.as_wire_name()
175                )
176            }
177            Self::InvalidParams { reason } => {
178                write!(formatter, "invalid params: {reason}")
179            }
180            Self::InternalError { message } => {
181                write!(formatter, "internal error: {message}")
182            }
183        }
184    }
185}
186
187impl std::error::Error for AcpError {}
188
189/// The per-request context the ACP handler
190/// receives.
191///
192/// Carries the negotiated protocol version (so
193/// the handler can decode per-revision params
194/// against it) and the auth context (so a
195/// handler that needs to check identity has it
196/// without re-parsing the wire envelope). Future
197/// fields land here as the protocol grows: the
198/// request id, the request deadline, the session
199/// id extracted from the params (where it is
200/// always present), the cancellation handle for
201/// the in-flight prompt.
202#[derive(Debug, Clone)]
203pub struct AcpRequestContext {
204    /// The negotiated ACP protocol version. The
205    /// handler is responsible for decoding params
206    /// against this version's wire shape.
207    pub protocol_version: crate::domain::acp::protocol_version::AcpProtocolVersion,
208}
209
210/// The consumer's extension point for ACP
211/// methods.
212///
213/// The serving adapter calls [`dispatch`](Self::dispatch)
214/// once per JSON-RPC request. The handler receives
215/// the method (already validated against the
216/// ACP vocabulary) and the raw `serde_json::Value`
217/// params. The handler returns the raw result
218/// value, or an [`AcpError`].
219///
220/// # Object safety
221///
222/// The trait is object-safe. A consumer who wants
223/// to swap handlers at runtime holds an
224/// `Arc<dyn AcpRequestHandler>`; the serving
225/// adapter accepts either an `Arc<dyn>` or a
226/// concrete `H: AcpRequestHandler`.
227///
228/// # Default impls
229///
230/// None of the methods have a default impl. A
231/// consumer who wants to leave a method
232/// unimplemented can return
233/// [`AcpError::MethodNotImplemented`] from
234/// `dispatch`; the serving adapter maps that to
235/// JSON-RPC `-32601` and the caller's client
236/// knows the method is not supported.
237///
238/// # Example
239///
240/// A minimal handler that implements only
241/// `initialize` and returns
242/// `AcpError::MethodNotImplemented` for the
243/// other twelve methods. The full reference
244/// impl is in a future `acp_agent` example.
245///
246/// ```no_run
247/// use std::pin::Pin;
248/// use std::sync::Arc;
249///
250/// use futures_core::Future;
251/// use serde_json::{json, Value};
252///
253/// use protocol_droid::domain::acp::method::AgentMethod;
254/// use protocol_droid::application::acp::{
255///     AcpError, AcpRequestContext, AcpRequestHandler,
256/// };
257///
258/// struct InitializeOnlyAgent;
259///
260/// impl AcpRequestHandler for InitializeOnlyAgent {
261///     fn dispatch(
262///         &self,
263///         method: AgentMethod,
264///         _params: Option<Value>,
265///         _context: &AcpRequestContext,
266///     ) -> Pin<
267///         Box<dyn Future<Output = Result<Value, AcpError>> + Send + '_>,
268///     > {
269///         Box::pin(async move {
270///             match method {
271///                 AgentMethod::Initialize => {
272///                     // Run the agent's initialize
273///                     // handshake, return the
274///                     // response as JSON.
275///                     Ok(json!({"protocolVersion": 1, "agentInfo": {}}))
276///                 }
277///                 other => Err(AcpError::MethodNotImplemented {
278///                     method: other,
279///                 }),
280///             }
281///         })
282///     }
283/// }
284///
285/// let _handler: Arc<dyn AcpRequestHandler> = Arc::new(InitializeOnlyAgent);
286/// ```
287pub trait AcpRequestHandler: Send + Sync + 'static {
288    /// Handles a routed ACP method.
289    ///
290    /// The serving adapter guarantees:
291    /// - `method` was looked up in the ACP
292    ///   vocabulary (so it is one of the 13
293    ///   agent methods, not a string);
294    /// - `params` is the raw JSON-RPC params
295    ///   object, or `None` if the request had no
296    ///   params.
297    ///
298    /// The handler is responsible for:
299    /// - Decoding `params` into the per-method
300    ///   type;
301    /// - Running the method's logic;
302    /// - Returning either the raw result value
303    ///   (which the serving adapter puts in the
304    ///   `result` field) or an [`AcpError`]
305    ///   (which becomes the `error` field).
306    ///
307    /// # Errors
308    ///
309    /// Any [`AcpError`]. The serving adapter
310    /// answers with the right JSON-RPC code;
311    /// the handler never has to know the code.
312    fn dispatch(
313        &self,
314        method: AgentMethod,
315        params: Option<Value>,
316        context: &AcpRequestContext,
317    ) -> Pin<Box<dyn Future<Output = Result<Value, AcpError>> + Send + '_>>;
318}
319
320#[cfg(test)]
321mod tests {
322    use super::AcpError;
323    use crate::domain::acp::method::AgentMethod;
324
325    #[test]
326    fn method_not_found_is_minus_32601() {
327        assert_eq!(AcpError::MethodNotFound.code(), -32601);
328        assert_eq!(AcpError::MethodNotFound.message(), "Method not found");
329    }
330
331    #[test]
332    fn method_not_implemented_is_minus_32601() {
333        // Same code as `MethodNotFound`; the
334        // distinction is in the message.
335        let err = AcpError::MethodNotImplemented {
336            method: AgentMethod::SessionPrompt,
337        };
338        assert_eq!(err.code(), -32601);
339        assert_eq!(err.message(), "Method not implemented");
340    }
341
342    #[test]
343    fn invalid_params_is_minus_32602() {
344        let err = AcpError::InvalidParams {
345            reason: "expected an object".to_owned(),
346        };
347        assert_eq!(err.code(), -32602);
348        assert_eq!(err.message(), "Invalid params");
349    }
350
351    #[test]
352    fn internal_error_is_minus_32603() {
353        let err = AcpError::InternalError {
354            message: "session store is down".to_owned(),
355        };
356        assert_eq!(err.code(), -32603);
357        assert_eq!(err.message(), "Internal error");
358    }
359
360    #[test]
361    fn display_distinguishes_the_two_minus_32601_variants() {
362        let not_found = AcpError::MethodNotFound;
363        let not_impl = AcpError::MethodNotImplemented {
364            method: AgentMethod::SessionPrompt,
365        };
366        // The two are the same on the wire; the
367        // distinction is the consumer's log
368        // line.
369        assert_ne!(not_found.to_string(), not_impl.to_string());
370    }
371}