Skip to main content

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}