a2a_rs/adapter/business/message_handler.rs
1//! Default message handler implementation.
2//!
3//! `ResponderMessageHandler` owns the *plumbing* of turning an incoming message
4//! into a task — parse the id, create the task if absent, append the message to
5//! history, broadcast each transition — and delegates the *business decision*
6//! (what to reply, and what state the task should end in) to an injected
7//! [`Responder`]. The built-in [`EchoResponder`] echoes the message back; a
8//! caller that wants AI behaviour implements `Responder` and keeps all of the
9//! lifecycle + streaming wiring for free.
10//!
11//! This split keeps the broadcasting in one place: because the handler holds
12//! both the lifecycle and streaming ports it hosts the [`TaskStatusBroadcast`]
13//! mixin, so every transition it drives — the incoming-message append *and* the
14//! responder's reply — goes through [`update_and_broadcast`], announcing to
15//! streaming subscribers. Storage mutators are persistence-only and do not
16//! self-broadcast, so a `Responder` author never has to think about streaming
17//! at all.
18//!
19//! `Responder` is synchronous-shaped (`message + task → reply + state`); agents
20//! that need "acknowledge now, finish later" semantics implement
21//! [`AsyncMessageHandler`](crate::port::AsyncMessageHandler) directly and host
22//! the mixin themselves (the reimbursement agent does this).
23//!
24//! [`update_and_broadcast`]: TaskStatusBroadcast::update_and_broadcast
25
26use std::sync::Arc;
27
28use async_trait::async_trait;
29
30use crate::{
31 application::{HasPushNotifier, HasStreaming, HasTaskLifecycle, TaskStatusBroadcast},
32 domain::{A2AError, ContextId, Message, Part, Role, Task, TaskId, TaskState},
33 port::{AsyncMessageHandler, AsyncPushNotifier, AsyncStreamingHandler, AsyncTaskLifecycle},
34};
35
36/// The business decision behind a message handler: given the incoming `message`
37/// and the `task` as it now stands (already in `Working` with the message
38/// appended to history), produce the agent's reply and the state the task
39/// should transition to.
40///
41/// Implement this to plug custom logic (an LLM call, a rules engine, …) into
42/// [`ResponderMessageHandler`] without re-implementing task lifecycle or
43/// streaming. Implementations must be cheap to share (`Send + Sync`): the
44/// handler holds the responder behind an `Arc`.
45#[async_trait]
46pub trait Responder: Send + Sync {
47 /// Produce the reply message and the resulting task state.
48 async fn respond(
49 &self,
50 message: &Message,
51 task: &Task,
52 ) -> Result<(Message, TaskState), A2AError>;
53}
54
55/// The reference [`Responder`]: echoes the incoming text back and completes the
56/// task. Useful for smoke tests, examples, and as the default for
57/// [`ResponderMessageHandler::echo`].
58#[derive(Clone, Debug, Default)]
59pub struct EchoResponder;
60
61#[async_trait]
62impl Responder for EchoResponder {
63 async fn respond(
64 &self,
65 message: &Message,
66 task: &Task,
67 ) -> Result<(Message, TaskState), A2AError> {
68 let echoed = message
69 .parts
70 .iter()
71 .filter_map(|p| p.get_text())
72 .collect::<Vec<_>>()
73 .join(" ");
74
75 let reply = Message::builder()
76 .role(Role::Agent)
77 .parts(vec![Part::text(format!("Echo: {}", echoed))])
78 .message_id(uuid::Uuid::new_v4().to_string())
79 .task_id(task.id.clone())
80 .context_id(message.context_id.clone())
81 .build();
82
83 // An echo has fully processed the message the moment it has the reply,
84 // so `Completed` is the only honest state. `Working` with the answer
85 // already attached is a wire-level lie: a conformant client that waits
86 // for a terminal state waits forever, which is what every A2A client
87 // pointed at `examples/jsonrpc_server` did. Agents that genuinely
88 // acknowledge now and finish later implement `AsyncMessageHandler`.
89 Ok((reply, TaskState::Completed))
90 }
91}
92
93/// A message handler that owns task-lifecycle plumbing and streaming
94/// announcements, delegating the reply to an injected [`Responder`].
95///
96/// Holds its ports as `Arc<dyn …>` trait objects (injected at the composition
97/// edge), so the handler carries no generic parameter. Because it holds both the
98/// lifecycle and streaming ports it is a host for the [`TaskStatusBroadcast`]
99/// capability mixin.
100#[derive(Clone)]
101pub struct ResponderMessageHandler {
102 /// Task lifecycle port for handling task operations
103 task_lifecycle: Arc<dyn AsyncTaskLifecycle>,
104 /// Streaming port for announcing status transitions to subscribers
105 streaming: Arc<dyn AsyncStreamingHandler>,
106 /// Push-notifier port for out-of-band webhook delivery on each transition
107 push_notifier: Arc<dyn AsyncPushNotifier>,
108 /// The business decision: what to reply and which state to end in
109 responder: Arc<dyn Responder>,
110}
111
112impl ResponderMessageHandler {
113 /// Create a handler with a custom [`Responder`].
114 ///
115 /// The lifecycle, streaming, and push-notifier ports are accepted separately
116 /// so the handler depends only on the capabilities it uses; at the
117 /// composition edge the streaming and push ports typically come from a
118 /// dedicated streaming adapter and the store's `push_notifier()`.
119 pub fn new(
120 task_lifecycle: impl AsyncTaskLifecycle + 'static,
121 streaming: impl AsyncStreamingHandler + 'static,
122 push_notifier: impl AsyncPushNotifier + 'static,
123 responder: impl Responder + 'static,
124 ) -> Self {
125 Self {
126 task_lifecycle: Arc::new(task_lifecycle),
127 streaming: Arc::new(streaming),
128 push_notifier: Arc::new(push_notifier),
129 responder: Arc::new(responder),
130 }
131 }
132
133 /// Create the reference echo handler ([`EchoResponder`]).
134 pub fn echo(
135 task_lifecycle: impl AsyncTaskLifecycle + 'static,
136 streaming: impl AsyncStreamingHandler + 'static,
137 push_notifier: impl AsyncPushNotifier + 'static,
138 ) -> Self {
139 Self::new(task_lifecycle, streaming, push_notifier, EchoResponder)
140 }
141}
142
143impl HasTaskLifecycle for ResponderMessageHandler {
144 fn lifecycle(&self) -> &dyn AsyncTaskLifecycle {
145 self.task_lifecycle.as_ref()
146 }
147}
148
149impl HasStreaming for ResponderMessageHandler {
150 fn streaming(&self) -> &dyn AsyncStreamingHandler {
151 self.streaming.as_ref()
152 }
153}
154
155impl HasPushNotifier for ResponderMessageHandler {
156 fn push_notifier(&self) -> &dyn AsyncPushNotifier {
157 self.push_notifier.as_ref()
158 }
159}
160
161#[async_trait]
162impl AsyncMessageHandler for ResponderMessageHandler {
163 async fn process_message(
164 &self,
165 task_id: &str,
166 message: &Message,
167 session_id: Option<&str>,
168 ) -> Result<Task, A2AError> {
169 let id: TaskId = task_id.parse()?;
170
171 // Create the task on first contact.
172 if !self.task_lifecycle.exists(&id).await? {
173 let context_id: ContextId = session_id.unwrap_or("default").parse()?;
174 self.task_lifecycle.create(&id, &context_id).await?;
175 }
176
177 // Append the incoming message to history (Working), announcing the
178 // transition to any streaming subscribers.
179 let task = self
180 .update_and_broadcast(&id, TaskState::Working, Some(message.clone()))
181 .await?;
182
183 // Delegate the business decision to the responder, then commit and
184 // announce its reply.
185 let (reply, state) = self.responder.respond(message, &task).await?;
186 let final_task = self.update_and_broadcast(&id, state, Some(reply)).await?;
187
188 Ok(final_task)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::adapter::storage::InMemoryTaskStorage;
196 use crate::adapter::streaming::InMemoryStreamingHandler;
197 use crate::domain::TaskStateExt;
198
199 /// A responder that ignores the input and drives the task to a terminal
200 /// state with a fixed reply — proof that the injected responder, not the
201 /// handler, owns the reply text and the final state.
202 struct FixedResponder;
203
204 #[async_trait]
205 impl Responder for FixedResponder {
206 async fn respond(
207 &self,
208 _message: &Message,
209 task: &Task,
210 ) -> Result<(Message, TaskState), A2AError> {
211 let reply = Message::builder()
212 .role(Role::Agent)
213 .parts(vec![Part::text("done".to_string())])
214 .message_id("fixed-1".to_string())
215 .task_id(task.id.clone())
216 .build();
217 Ok((reply, TaskState::Completed))
218 }
219 }
220
221 #[tokio::test]
222 async fn injected_responder_controls_reply_and_state() {
223 let storage = InMemoryTaskStorage::new();
224 let streaming = InMemoryStreamingHandler::new();
225 let push = storage.push_notifier();
226 let handler = ResponderMessageHandler::new(storage, streaming, push, FixedResponder);
227
228 let message = Message::user_text("anything".to_string(), "m1".to_string());
229 let task = handler.process_message("t1", &message, None).await.unwrap();
230
231 // The responder chose the terminal state...
232 assert_eq!(task.status.state, TaskState::Completed);
233 // ...and its reply landed in history (after the appended user message).
234 let replied = task.history.iter().any(|m| {
235 m.parts
236 .iter()
237 .filter_map(|p| p.get_text())
238 .any(|t| t == "done")
239 });
240 assert!(replied, "responder reply should be in task history");
241 }
242
243 /// The reference responder has to reach a terminal state. A client that
244 /// waits for one — which is every conformant A2A client, and `a2acli send`
245 /// by default — hangs against an agent that attaches its answer and keeps
246 /// saying `Working`.
247 #[tokio::test]
248 async fn the_echo_responder_completes_the_task() {
249 let storage = InMemoryTaskStorage::new();
250 let streaming = InMemoryStreamingHandler::new();
251 let push = storage.push_notifier();
252 let handler = ResponderMessageHandler::echo(storage, streaming, push);
253
254 let message = Message::user_text("ping".to_string(), "m1".to_string());
255 let task = handler.process_message("t1", &message, None).await.unwrap();
256
257 assert_eq!(task.status.state, TaskState::Completed);
258 assert!(task.status.state.is_terminal());
259 assert_eq!(
260 task.status
261 .message
262 .parts
263 .iter()
264 .filter_map(|p| p.get_text())
265 .collect::<Vec<_>>(),
266 ["Echo: ping"],
267 "the reply travels with the terminal status, not just in history"
268 );
269 }
270}