Skip to main content

a2a_rs/application/
task_service.rs

1//! The task application service: use-case orchestration over the port traits.
2//!
3//! `TaskService` is the **inner** half of the service/transport split: it owns
4//! the ports (`Arc<dyn …>`), orchestrates them, and speaks only the domain
5//! vocabulary (`Task`, `Message`, `TaskId`, `A2AError`). It knows nothing about
6//! ConnectRPC, `buffa` views, or wire error codes — that glue lives in the
7//! transport adapter ([`ConnectRpcAdapter`](crate::adapter::ConnectRpcAdapter)),
8//! which decodes wire requests into these domain calls and re-encodes the
9//! results.
10//!
11//! Because the service holds both the lifecycle and streaming ports it exposes
12//! them as mixin ingredients ([`HasTaskLifecycle`], [`HasStreaming`]) and so
13//! gains [`TaskStatusBroadcast::update_and_broadcast`] for free
14//! (`.claude/rules/hexagonal_architecture.md` §9). The accessors return `&dyn`
15//! **ports**, never the concrete adapters behind them, so the dependency arrow
16//! still points inward.
17//!
18//! [`TaskStatusBroadcast::update_and_broadcast`]: crate::application::TaskStatusBroadcast::update_and_broadcast
19
20use std::pin::Pin;
21use std::sync::Arc;
22use std::time::Duration;
23
24use futures::{Stream, StreamExt};
25
26use crate::application::{HasPushNotifier, HasStreaming, HasTaskLifecycle, TaskStatusBroadcast};
27use crate::domain::SendCompletion;
28use crate::domain::core::task::TaskStateExt;
29use crate::domain::{
30    A2AError, AgentCard, DeleteTaskPushNotificationConfigParams,
31    GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigsParams, ListTasksParams,
32    ListTasksResult, Message, Task, TaskId, TaskPushNotificationConfig,
33};
34use crate::port::{
35    AsyncMessageHandler, AsyncNotificationManager, AsyncNotificationManagerExt, AsyncPushNotifier,
36    AsyncStreamingHandler, AsyncTaskLifecycle, AsyncTaskQuery, SeqEvent,
37};
38use crate::services::server::AgentInfoProvider;
39
40/// A stream of sequenced update events for a task. Each [`SeqEvent`] carries a
41/// per-task monotonic id (surfaced as the SSE `id:` field); the transport
42/// adapter maps the inner update onto its wire representation.
43pub type UpdateStream = Pin<Box<dyn Stream<Item = Result<SeqEvent, A2AError>> + Send>>;
44
45/// The optional knobs on a `SendMessage` request, decoded once from the wire
46/// `SendMessageConfiguration` and shared by both transports.
47///
48/// Grouped into a struct rather than threaded as three more positional
49/// parameters: the call already carried five, and a bare `Option<u32>` next to
50/// a bare `bool` is exactly the signature where an argument gets passed in the
51/// wrong slot.
52#[derive(Debug, Clone, Default)]
53pub struct SendOptions {
54    /// Push-notification config to register for the task before processing.
55    pub push_config: Option<TaskPushNotificationConfig>,
56    /// Truncate the returned task's history to this many messages.
57    pub history_limit: Option<u32>,
58    /// Whether to hold the response until the task settles.
59    pub completion: SendCompletion,
60}
61
62/// End `stream` after — and including — the event that settles the task.
63///
64/// The rule lives here rather than in each transport adapter because every
65/// transport gets its stream from this service, and "the subscription outlives
66/// the task" is the same bug in each of them. A check that runs in one entry
67/// point and not the other is not a check (`NOTES.md`).
68///
69/// Implemented with `unfold` rather than `take_while`/`scan` for a reason that
70/// is easy to get wrong: those combinators only decide to stop when the *next*
71/// item arrives, and after a terminal state no next item ever arrives — the
72/// underlying broadcast receiver simply parks. The stream would hang open on
73/// exactly the events it is supposed to close on. Carrying the inner stream in
74/// an `Option` and dropping it on settle terminates without polling again, and
75/// dropping it is also what releases the subscription.
76fn until_settled(stream: UpdateStream) -> UpdateStream {
77    Box::pin(futures::stream::unfold(Some(stream), |state| async move {
78        let mut stream = state?;
79        let item = stream.next().await?;
80        let settled = matches!(&item, Ok(seq) if seq.event.settles_task());
81        Some((item, (!settled).then_some(stream)))
82    }))
83}
84
85/// Use-case orchestration over the A2A ports.
86///
87/// Constructed at the composition edge with concrete adapters injected; the
88/// fields are `Arc<dyn …>` so the service type carries no generic parameters.
89/// All methods return domain types and [`A2AError`] — there is no transport
90/// vocabulary in this layer.
91#[derive(Clone)]
92pub struct TaskService {
93    message_handler: Arc<dyn AsyncMessageHandler>,
94    task_lifecycle: Arc<dyn AsyncTaskLifecycle>,
95    task_query: Arc<dyn AsyncTaskQuery>,
96    notification_manager: Arc<dyn AsyncNotificationManager>,
97    agent_info: Arc<dyn AgentInfoProvider>,
98    streaming_handler: Arc<dyn AsyncStreamingHandler>,
99    push_notifier: Arc<dyn AsyncPushNotifier>,
100    send_wait: Duration,
101}
102
103/// How long a blocking `SendMessage` waits before returning the task unsettled.
104///
105/// **Must stay below the client's per-request timeout**, which is 30s for both
106/// `JsonRpcClient` and `HttpClient` (and for `a2acli`, whose `--timeout`
107/// defaults to theirs). If the server waited the full 30s the two would race,
108/// and the client would report a transport timeout instead of receiving the
109/// unsettled task the wait is supposed to hand back — turning a slow agent into
110/// a connection error. The 5s of headroom is for the response itself.
111///
112/// Raising this without raising the client timeout re-creates that race.
113const DEFAULT_SEND_WAIT: Duration = Duration::from_secs(25);
114
115impl TaskService {
116    /// Assemble a service from separate handlers.
117    ///
118    /// `tasks` supplies both the lifecycle and query capabilities; it is
119    /// stored once and shared between the two `Arc<dyn …>` fields.
120    pub fn new(
121        message_handler: impl AsyncMessageHandler + 'static,
122        tasks: impl AsyncTaskLifecycle + AsyncTaskQuery + 'static,
123        notification_manager: impl AsyncNotificationManager + 'static,
124        agent_info: impl AgentInfoProvider + 'static,
125        streaming_handler: impl AsyncStreamingHandler + 'static,
126        push_notifier: impl AsyncPushNotifier + 'static,
127    ) -> Self {
128        let tasks = Arc::new(tasks);
129        Self {
130            message_handler: Arc::new(message_handler),
131            task_lifecycle: tasks.clone(),
132            task_query: tasks,
133            notification_manager: Arc::new(notification_manager),
134            agent_info: Arc::new(agent_info),
135            streaming_handler: Arc::new(streaming_handler),
136            push_notifier: Arc::new(push_notifier),
137            send_wait: DEFAULT_SEND_WAIT,
138        }
139    }
140
141    /// Assemble a service from a single handler that implements every port.
142    pub fn with_handler(
143        handler: impl AsyncMessageHandler
144        + AsyncTaskLifecycle
145        + AsyncTaskQuery
146        + AsyncNotificationManager
147        + 'static,
148        agent_info: impl AgentInfoProvider + 'static,
149        streaming_handler: impl AsyncStreamingHandler + 'static,
150        push_notifier: impl AsyncPushNotifier + 'static,
151    ) -> Self {
152        let handler = Arc::new(handler);
153        Self {
154            message_handler: handler.clone(),
155            task_lifecycle: handler.clone(),
156            task_query: handler.clone(),
157            notification_manager: handler,
158            agent_info: Arc::new(agent_info),
159            streaming_handler: Arc::new(streaming_handler),
160            push_notifier: Arc::new(push_notifier),
161            send_wait: DEFAULT_SEND_WAIT,
162        }
163    }
164
165    /// Replace the streaming handler, returning the updated service.
166    pub fn with_streaming_handler(
167        mut self,
168        streaming_handler: impl AsyncStreamingHandler + 'static,
169    ) -> Self {
170        self.streaming_handler = Arc::new(streaming_handler);
171        self
172    }
173
174    /// Replace the push notifier, returning the updated service.
175    pub fn with_push_notifier(mut self, push_notifier: impl AsyncPushNotifier + 'static) -> Self {
176        self.push_notifier = Arc::new(push_notifier);
177        self
178    }
179
180    /// How long a blocking `SendMessage` waits for the task to settle before
181    /// returning it unsettled. Defaults to 25s.
182    ///
183    /// Raise it for agents that legitimately take minutes — but raise the
184    /// calling client's request timeout with it. The two are a pair: whichever
185    /// is shorter decides what the caller sees, and if the client gives up
186    /// first it gets a transport error instead of the task.
187    pub fn with_send_wait(mut self, send_wait: Duration) -> Self {
188        self.send_wait = send_wait;
189        self
190    }
191
192    /// Process a message for a task, optionally configuring push notifications
193    /// and limiting the returned history.
194    ///
195    /// With [`SendCompletion::WhenSettled`] — the spec default — the response is
196    /// held until the task reaches a terminal or interrupted state, bounded by
197    /// [`with_send_wait`]. The wait is driven by the streaming handler rather
198    /// than a poll loop: it already broadcasts every transition, so a subscriber
199    /// *is* the wait.
200    ///
201    /// Two ordering details are load-bearing. The subscription is opened
202    /// **before** `process_message`, because a handler that finishes
203    /// synchronously (the echo responder does) broadcasts its terminal event
204    /// during that call — subscribing afterwards would miss it and then wait
205    /// for a transition that has already happened. And the task is re-fetched
206    /// after the wait rather than assembled from the event, because the event
207    /// carries a status, not the artifacts and history the caller asked for.
208    ///
209    /// [`with_send_wait`]: TaskService::with_send_wait
210    pub async fn send_message(
211        &self,
212        task_id: &str,
213        message: &Message,
214        session_id: Option<&str>,
215        opts: SendOptions,
216    ) -> Result<Task, A2AError> {
217        if let Some(mut push_config) = opts.push_config {
218            push_config.task_id = task_id.to_string();
219            self.notification_manager
220                .set_validated(&push_config)
221                .await?;
222        }
223
224        let updates = match opts.completion {
225            SendCompletion::WhenCreated => None,
226            // A handler with no streaming backend (`NoopStreamingHandler`)
227            // reports `UnsupportedOperation` here. That is not a reason to fail
228            // the send: it means this server cannot observe transitions, so the
229            // most it can honestly do is return what it has.
230            SendCompletion::WhenSettled => self
231                .streaming_handler
232                .start_task_streaming(task_id, None)
233                .await
234                .ok(),
235        };
236
237        let mut task = self
238            .message_handler
239            .process_message(task_id, message, session_id)
240            .await?;
241
242        if let Some(updates) = updates
243            && !task.status.state.is_settled()
244        {
245            task = self.wait_for_settled(task_id, updates).await?;
246        }
247
248        if let Some(limit) = opts.history_limit {
249            task = task.with_limited_history(Some(limit));
250        }
251
252        Ok(task)
253    }
254
255    /// Block on `updates` until the task settles or the budget runs out, then
256    /// return the task as stored.
257    ///
258    /// On expiry the *current* task is returned rather than an error. The state
259    /// it carries is true — `WORKING` says exactly that the agent has not
260    /// finished — so the caller gets a usable task id and can follow it, which
261    /// an error would deny them. The bound exists because the spec's "MUST
262    /// wait" has no escape clause, and an agent that never finishes would
263    /// otherwise pin the connection for as long as the client tolerates it.
264    async fn wait_for_settled(
265        &self,
266        task_id: &str,
267        updates: UpdateStream,
268    ) -> Result<Task, A2AError> {
269        let id: TaskId = task_id.parse()?;
270
271        // `until_settled` ends the stream on the settling event, so draining it
272        // to completion *is* the wait — no per-item inspection needed.
273        let drained = tokio::time::timeout(self.send_wait, async {
274            let mut updates = until_settled(updates);
275            while updates.next().await.is_some() {}
276        })
277        .await;
278
279        if drained.is_err() {
280            #[cfg(feature = "tracing")]
281            tracing::debug!(
282                task_id,
283                timeout_secs = self.send_wait.as_secs(),
284                "send_message gave up waiting for the task to settle; returning it unsettled"
285            );
286        }
287
288        self.task_lifecycle.get(&id, None).await
289    }
290
291    /// Process a message and subscribe to its update stream.
292    ///
293    /// The update stream is started **before** the message is processed so no
294    /// early updates are missed. Returns the initial task and the stream; the
295    /// caller is responsible for emitting the initial task ahead of stream
296    /// items.
297    ///
298    /// The stream ends once the task settles (see [`until_settled`]), so a
299    /// caller that reads to completion is not left holding an open connection
300    /// to a finished task.
301    pub async fn send_streaming_message(
302        &self,
303        task_id: &str,
304        message: &Message,
305        session_id: Option<&str>,
306        push_config: Option<TaskPushNotificationConfig>,
307        history_limit: Option<u32>,
308    ) -> Result<(Task, UpdateStream), A2AError> {
309        if let Some(mut push_config) = push_config {
310            push_config.task_id = task_id.to_string();
311            self.notification_manager
312                .set_validated(&push_config)
313                .await?;
314        }
315
316        // Start updates stream first so we don't miss early updates.
317        let update_stream = self
318            .streaming_handler
319            .start_task_streaming(task_id, None)
320            .await?;
321
322        let mut task = self
323            .message_handler
324            .process_message(task_id, message, session_id)
325            .await?;
326
327        if let Some(limit) = history_limit {
328            task = task.with_limited_history(Some(limit));
329        }
330
331        Ok((task, until_settled(update_stream)))
332    }
333
334    /// Get a task by ID with optional history length limit.
335    pub async fn get(&self, id: &TaskId, history_length: Option<u32>) -> Result<Task, A2AError> {
336        self.task_lifecycle.get(id, history_length).await
337    }
338
339    /// List tasks with filtering and pagination.
340    pub async fn list(&self, params: &ListTasksParams) -> Result<ListTasksResult, A2AError> {
341        self.task_query.list(params).await
342    }
343
344    /// Cancel a task, then announce the terminal status to streaming
345    /// subscribers.
346    ///
347    /// Storage no longer self-broadcasts on cancellation (§4.0.2), so the
348    /// service owns the "commit then announce" step via the
349    /// [`TaskStatusBroadcast`] mixin it hosts.
350    pub async fn cancel(&self, id: &TaskId) -> Result<Task, A2AError> {
351        self.cancel_and_broadcast(id).await
352    }
353
354    /// Subscribe to a task's update stream, returning the current task (if it
355    /// exists) and the stream of subsequent updates.
356    ///
357    /// `from_event_id` carries a client's `Last-Event-ID` for resumption: when
358    /// set, the handler replays buffered events with a greater id before
359    /// streaming live updates.
360    ///
361    /// The stream ends once the task settles (see [`until_settled`]). If the
362    /// task is *already* terminal and no resumption point was given, the caller
363    /// gets its snapshot and an empty stream, because nothing further can ever
364    /// be broadcast for it.
365    ///
366    /// That short-circuit is conditional on `from_event_id` being unset, and
367    /// that condition is load-bearing: resuming after a disconnect on a task
368    /// that has since finished is precisely when the replay buffer matters —
369    /// the events the client missed are the ones it reconnected for. Skipping
370    /// the handler because the task looks finished would turn resumption into
371    /// silence.
372    ///
373    /// A task already sitting in an interrupted state (`INPUT_REQUIRED`,
374    /// `AUTH_REQUIRED`) deliberately does **not** short-circuit: it resumes
375    /// under the same id once the caller supplies what it asked for, and a
376    /// subscriber that attached first is entitled to watch that happen. The
377    /// asymmetry with [`UpdateEvent::settles_task`] is the point — arriving at
378    /// an interrupted state ends a stream, finding one already there does not.
379    ///
380    /// [`UpdateEvent::settles_task`]: crate::port::UpdateEvent::settles_task
381    pub async fn subscribe(
382        &self,
383        task_id: &str,
384        from_event_id: Option<u64>,
385    ) -> Result<(Option<Task>, UpdateStream), A2AError> {
386        let id: TaskId = task_id.parse()?;
387
388        let initial_task = match self.task_lifecycle.get(&id, None).await {
389            Ok(task) => Some(task),
390            Err(A2AError::TaskNotFound(_)) => None,
391            Err(e) => return Err(e),
392        };
393
394        if from_event_id.is_none()
395            && let Some(task) = &initial_task
396            && task.status.state.is_terminal()
397        {
398            return Ok((initial_task, Box::pin(futures::stream::empty())));
399        }
400
401        let update_stream = self
402            .streaming_handler
403            .start_task_streaming(task_id, from_event_id)
404            .await?;
405
406        Ok((initial_task, until_settled(update_stream)))
407    }
408
409    /// Create or replace a push-notification config (validated).
410    pub async fn set_push_config(
411        &self,
412        config: &TaskPushNotificationConfig,
413    ) -> Result<TaskPushNotificationConfig, A2AError> {
414        self.notification_manager.set_validated(config).await
415    }
416
417    /// Get a push-notification config for a task.
418    pub async fn get_push_config(
419        &self,
420        params: &GetTaskPushNotificationConfigParams,
421    ) -> Result<TaskPushNotificationConfig, A2AError> {
422        self.notification_manager.get_config(params).await
423    }
424
425    /// List push-notification configs for a task.
426    pub async fn list_push_configs(
427        &self,
428        params: &ListTaskPushNotificationConfigsParams,
429    ) -> Result<Vec<TaskPushNotificationConfig>, A2AError> {
430        self.notification_manager.list_configs(params).await
431    }
432
433    /// Delete a push-notification config.
434    pub async fn delete_push_config(
435        &self,
436        params: &DeleteTaskPushNotificationConfigParams,
437    ) -> Result<(), A2AError> {
438        self.notification_manager.delete_config(params).await
439    }
440
441    /// Fetch the authenticated extended agent card.
442    pub async fn extended_agent_card(&self) -> Result<AgentCard, A2AError> {
443        self.agent_info.get_authenticated_extended_card().await
444    }
445}
446
447// The service is the composed assembly holding both the lifecycle and streaming
448// ports, so it exposes them as mixin ingredients (see
449// `.claude/rules/hexagonal_architecture.md` §9). This grants it the
450// `TaskStatusBroadcast::update_and_broadcast` "commit then announce" capability
451// for free, without coupling either port to the other. The accessors return
452// `&dyn` **ports**, never the concrete adapters behind them.
453impl HasTaskLifecycle for TaskService {
454    fn lifecycle(&self) -> &dyn AsyncTaskLifecycle {
455        self.task_lifecycle.as_ref()
456    }
457}
458
459impl HasStreaming for TaskService {
460    fn streaming(&self) -> &dyn AsyncStreamingHandler {
461        self.streaming_handler.as_ref()
462    }
463}
464
465impl HasPushNotifier for TaskService {
466    fn push_notifier(&self) -> &dyn AsyncPushNotifier {
467        self.push_notifier.as_ref()
468    }
469}