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