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::{
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}