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}