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, ContextId, DeleteTaskPushNotificationConfigParams,
31    GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigsParams, ListTasksParams,
32    ListTasksResult, Message, Task, TaskId, TaskPushNotificationConfig,
33};
34use crate::port::{
35    AsyncMessageHandler, AsyncNotificationManager, AsyncNotificationManagerExt, AsyncPushNotifier,
36    AsyncStreamingHandler, AsyncTaskLifecycle, AsyncTaskQuery, RequestContext, SeqEvent,
37};
38use crate::services::server::AgentInfoProvider;
39
40/// The id a client actually sent, or `None` when the field was omitted.
41///
42/// proto3 delivers an omitted string as `""`, and `TaskId`/`ContextId` reject
43/// blank input, so those are one test. The value passes through untrimmed: an
44/// id normalized here would key the task differently from a `GetTask` asking
45/// for the same string.
46fn supplied(id: &str) -> Option<&str> {
47    (!id.trim().is_empty()).then_some(id)
48}
49
50/// A stream of sequenced update events for a task. Each [`SeqEvent`] carries a
51/// per-task monotonic id (surfaced as the SSE `id:` field); the transport
52/// adapter maps the inner update onto its wire representation.
53pub type UpdateStream = Pin<Box<dyn Stream<Item = Result<SeqEvent, A2AError>> + Send>>;
54
55/// The optional knobs on a `SendMessage` request, decoded once from the wire
56/// `SendMessageConfiguration` and shared by both transports.
57///
58/// Grouped into a struct rather than threaded as three more positional
59/// parameters: the call already carried five, and a bare `Option<u32>` next to
60/// a bare `bool` is exactly the signature where an argument gets passed in the
61/// wrong slot.
62#[derive(Debug, Clone, Default)]
63pub struct SendOptions {
64    /// Push-notification config to register for the task before processing.
65    pub push_config: Option<TaskPushNotificationConfig>,
66    /// Truncate the returned task's history to this many messages.
67    pub history_limit: Option<u32>,
68    /// Whether to hold the response until the task settles.
69    pub completion: SendCompletion,
70}
71
72/// End `stream` after — and including — the event that settles the task.
73///
74/// The rule lives here rather than in each transport adapter because every
75/// transport gets its stream from this service, and "the subscription outlives
76/// the task" is the same bug in each of them. A check that runs in one entry
77/// point and not the other is not a check (`NOTES.md`).
78///
79/// Implemented with `unfold` rather than `take_while`/`scan` for a reason that
80/// is easy to get wrong: those combinators only decide to stop when the *next*
81/// item arrives, and after a terminal state no next item ever arrives — the
82/// underlying broadcast receiver simply parks. The stream would hang open on
83/// exactly the events it is supposed to close on. Carrying the inner stream in
84/// an `Option` and dropping it on settle terminates without polling again, and
85/// dropping it is also what releases the subscription.
86fn until_settled(stream: UpdateStream) -> UpdateStream {
87    Box::pin(futures::stream::unfold(Some(stream), |state| async move {
88        let mut stream = state?;
89        let item = stream.next().await?;
90        let settled = matches!(&item, Ok(seq) if seq.event.settles_task());
91        Some((item, (!settled).then_some(stream)))
92    }))
93}
94
95/// Deliver what the stream already holds, then end.
96///
97/// The companion to [`until_settled`] for a task that was **already settled**
98/// when its stream was handed over. No settling event can arrive to close that
99/// stream — whatever would have sent one has finished — so `until_settled`
100/// waits forever on it. That is not hypothetical: a handler that answers
101/// synchronously, which is what the echo handlers and every quick agent do,
102/// returns a `COMPLETED` task having broadcast nothing, and the SSE response
103/// then never ended. The official `a2acli` hung on it until its timeout.
104///
105/// Whatever the handler *did* broadcast on the way is queued and still worth
106/// delivering — an artifact chunk it streamed without attaching to the task
107/// exists nowhere else — so this drains the queue rather than dropping the
108/// stream outright. `poll_immediate` never parks, so the `take_while` here has
109/// none of the "decides one item late" problem described on [`until_settled`].
110fn ready_queued(stream: UpdateStream) -> UpdateStream {
111    Box::pin(
112        futures::stream::poll_immediate(stream)
113            .take_while(|item| futures::future::ready(item.is_ready()))
114            .filter_map(|item| {
115                futures::future::ready(match item {
116                    std::task::Poll::Ready(item) => Some(item),
117                    std::task::Poll::Pending => None,
118                })
119            }),
120    )
121}
122
123/// Use-case orchestration over the A2A ports.
124///
125/// Constructed at the composition edge with concrete adapters injected; the
126/// fields are `Arc<dyn …>` so the service type carries no generic parameters.
127/// All methods return domain types and [`A2AError`] — there is no transport
128/// vocabulary in this layer.
129#[derive(Clone)]
130pub struct TaskService {
131    message_handler: Arc<dyn AsyncMessageHandler>,
132    task_lifecycle: Arc<dyn AsyncTaskLifecycle>,
133    task_query: Arc<dyn AsyncTaskQuery>,
134    notification_manager: Arc<dyn AsyncNotificationManager>,
135    agent_info: Arc<dyn AgentInfoProvider>,
136    streaming_handler: Arc<dyn AsyncStreamingHandler>,
137    push_notifier: Arc<dyn AsyncPushNotifier>,
138    send_wait: Duration,
139}
140
141/// How long a blocking `SendMessage` waits before returning the task unsettled.
142///
143/// **Must stay below the client's per-request timeout**, which is 30s for both
144/// `JsonRpcClient` and `HttpClient` (and for `a2acli`, whose `--timeout`
145/// defaults to theirs). If the server waited the full 30s the two would race,
146/// and the client would report a transport timeout instead of receiving the
147/// unsettled task the wait is supposed to hand back — turning a slow agent into
148/// a connection error. The 5s of headroom is for the response itself.
149///
150/// Raising this without raising the client timeout re-creates that race.
151const DEFAULT_SEND_WAIT: Duration = Duration::from_secs(25);
152
153impl TaskService {
154    /// Assemble a service from separate handlers.
155    ///
156    /// `tasks` supplies both the lifecycle and query capabilities; it is
157    /// stored once and shared between the two `Arc<dyn …>` fields.
158    pub fn new(
159        message_handler: impl AsyncMessageHandler + 'static,
160        tasks: impl AsyncTaskLifecycle + AsyncTaskQuery + 'static,
161        notification_manager: impl AsyncNotificationManager + 'static,
162        agent_info: impl AgentInfoProvider + 'static,
163        streaming_handler: impl AsyncStreamingHandler + 'static,
164        push_notifier: impl AsyncPushNotifier + 'static,
165    ) -> Self {
166        let tasks = Arc::new(tasks);
167        Self {
168            message_handler: Arc::new(message_handler),
169            task_lifecycle: tasks.clone(),
170            task_query: tasks,
171            notification_manager: Arc::new(notification_manager),
172            agent_info: Arc::new(agent_info),
173            streaming_handler: Arc::new(streaming_handler),
174            push_notifier: Arc::new(push_notifier),
175            send_wait: DEFAULT_SEND_WAIT,
176        }
177    }
178
179    /// Assemble a service from a single handler that implements every port.
180    pub fn with_handler(
181        handler: impl AsyncMessageHandler
182        + AsyncTaskLifecycle
183        + AsyncTaskQuery
184        + AsyncNotificationManager
185        + 'static,
186        agent_info: impl AgentInfoProvider + 'static,
187        streaming_handler: impl AsyncStreamingHandler + 'static,
188        push_notifier: impl AsyncPushNotifier + 'static,
189    ) -> Self {
190        let handler = Arc::new(handler);
191        Self {
192            message_handler: handler.clone(),
193            task_lifecycle: handler.clone(),
194            task_query: handler.clone(),
195            notification_manager: handler,
196            agent_info: Arc::new(agent_info),
197            streaming_handler: Arc::new(streaming_handler),
198            push_notifier: Arc::new(push_notifier),
199            send_wait: DEFAULT_SEND_WAIT,
200        }
201    }
202
203    /// Replace the streaming handler, returning the updated service.
204    pub fn with_streaming_handler(
205        mut self,
206        streaming_handler: impl AsyncStreamingHandler + 'static,
207    ) -> Self {
208        self.streaming_handler = Arc::new(streaming_handler);
209        self
210    }
211
212    /// Replace the push notifier, returning the updated service.
213    pub fn with_push_notifier(mut self, push_notifier: impl AsyncPushNotifier + 'static) -> Self {
214        self.push_notifier = Arc::new(push_notifier);
215        self
216    }
217
218    /// How long a blocking `SendMessage` waits for the task to settle before
219    /// returning it unsettled. Defaults to 25s.
220    ///
221    /// Raise it for agents that legitimately take minutes — but raise the
222    /// calling client's request timeout with it. The two are a pair: whichever
223    /// is shorter decides what the caller sees, and if the client gives up
224    /// first it gets a transport error instead of the task.
225    pub fn with_send_wait(mut self, send_wait: Duration) -> Self {
226        self.send_wait = send_wait;
227        self
228    }
229
230    /// Resolve the task and context ids for an incoming client message.
231    ///
232    /// Both are optional on the wire (`a2a.proto`'s `Message`), and proto3 has
233    /// no "absent" for a scalar string, so an omitted id arrives as `""`. The
234    /// rules, from the same paragraph of the spec:
235    ///
236    /// - No task id: the server assigns one, and a context id with it unless the
237    ///   caller supplied one.
238    /// - A task id naming a task we hold: that task's context wins. A caller
239    ///   that supplied a *different* context is rejected rather than silently
240    ///   re-homed — the spec requires the two to match.
241    /// - A task id we have never seen: the client picked the id; treat it as new.
242    async fn resolve_ids(&self, message: &Message) -> Result<(TaskId, ContextId), A2AError> {
243        let supplied_context = supplied(&message.context_id)
244            .map(str::parse::<ContextId>)
245            .transpose()?;
246
247        let Some(task_id) = supplied(&message.task_id) else {
248            return Ok((
249                TaskId::generate(),
250                supplied_context.unwrap_or_else(ContextId::generate),
251            ));
252        };
253        let task_id = task_id.parse::<TaskId>()?;
254
255        // `Some(0)` asks for no history: this read exists to learn the context,
256        // not to fetch the conversation.
257        let stored_context = match self.task_lifecycle.get(&task_id, Some(0)).await {
258            Ok(task) => Some(task.context_id.parse::<ContextId>()?),
259            Err(A2AError::TaskNotFound(_)) => None,
260            Err(e) => return Err(e),
261        };
262
263        let context_id = match (stored_context, supplied_context) {
264            (Some(stored), Some(supplied)) if stored != supplied => {
265                return Err(A2AError::ValidationError {
266                    field: "context_id".to_string(),
267                    message: format!(
268                        "context_id {supplied} does not match task {task_id}'s context {stored}"
269                    ),
270                });
271            }
272            (Some(stored), _) => stored,
273            (None, Some(supplied)) => supplied,
274            (None, None) => ContextId::generate(),
275        };
276
277        Ok((task_id, context_id))
278    }
279
280    /// [`resolve_ids`], then write the resolved ids back onto the message.
281    ///
282    /// The stamp matters because this message is what lands in task history: a
283    /// client that sent no ids reads them back off `history[i]`, and a handler
284    /// that forwards the message elsewhere carries them along.
285    ///
286    /// [`resolve_ids`]: TaskService::resolve_ids
287    async fn stamp_ids(
288        &self,
289        mut message: Message,
290    ) -> Result<(TaskId, ContextId, Message), A2AError> {
291        let (task_id, context_id) = self.resolve_ids(&message).await?;
292        message.task_id = task_id.to_string();
293        message.context_id = context_id.to_string();
294        Ok((task_id, context_id, message))
295    }
296
297    /// Process a message for a task, optionally configuring push notifications
298    /// and limiting the returned history.
299    ///
300    /// With [`SendCompletion::WhenSettled`] — the spec default — the response is
301    /// held until the task reaches a terminal or interrupted state, bounded by
302    /// [`with_send_wait`]. The wait is driven by the streaming handler rather
303    /// than a poll loop: it already broadcasts every transition, so a subscriber
304    /// *is* the wait.
305    ///
306    /// Two ordering details are load-bearing. The subscription is opened
307    /// **before** `process_message`, because a handler that finishes
308    /// synchronously (the echo responder does) broadcasts its terminal event
309    /// during that call — subscribing afterwards would miss it and then wait
310    /// for a transition that has already happened. And the task is re-fetched
311    /// after the wait rather than assembled from the event, because the event
312    /// carries a status, not the artifacts and history the caller asked for.
313    ///
314    /// [`with_send_wait`]: TaskService::with_send_wait
315    pub async fn send_message(
316        &self,
317        message: Message,
318        ctx: &RequestContext,
319        opts: SendOptions,
320    ) -> Result<Task, A2AError> {
321        let (id, context_id, message) = self.stamp_ids(message).await?;
322        let ctx = ctx.clone().with_session(context_id.as_str());
323        let task_id = id.as_str();
324
325        if let Some(mut push_config) = opts.push_config {
326            push_config.task_id = task_id.to_string();
327            self.notification_manager
328                .set_validated(&push_config)
329                .await?;
330        }
331
332        let updates = match opts.completion {
333            SendCompletion::WhenCreated => None,
334            // A handler with no streaming backend (`NoopStreamingHandler`)
335            // reports `UnsupportedOperation` here. That is not a reason to fail
336            // the send: it means this server cannot observe transitions, so the
337            // most it can honestly do is return what it has.
338            SendCompletion::WhenSettled => self
339                .streaming_handler
340                .start_task_streaming(task_id, None)
341                .await
342                .ok(),
343        };
344
345        let mut task = self
346            .message_handler
347            .process_message(task_id, &message, &ctx)
348            .await?;
349
350        if let Some(updates) = updates
351            && !task.status.state.is_settled()
352        {
353            task = self.wait_for_settled(task_id, updates).await?;
354        }
355
356        if let Some(limit) = opts.history_limit {
357            task = task.with_limited_history(Some(limit));
358        }
359
360        Ok(task)
361    }
362
363    /// Block on `updates` until the task settles or the budget runs out, then
364    /// return the task as stored.
365    ///
366    /// On expiry the *current* task is returned rather than an error. The state
367    /// it carries is true — `WORKING` says exactly that the agent has not
368    /// finished — so the caller gets a usable task id and can follow it, which
369    /// an error would deny them. The bound exists because the spec's "MUST
370    /// wait" has no escape clause, and an agent that never finishes would
371    /// otherwise pin the connection for as long as the client tolerates it.
372    async fn wait_for_settled(
373        &self,
374        task_id: &str,
375        updates: UpdateStream,
376    ) -> Result<Task, A2AError> {
377        let id: TaskId = task_id.parse()?;
378
379        // `until_settled` ends the stream on the settling event, so draining it
380        // to completion *is* the wait — no per-item inspection needed.
381        let drained = tokio::time::timeout(self.send_wait, async {
382            let mut updates = until_settled(updates);
383            while updates.next().await.is_some() {}
384        })
385        .await;
386
387        if drained.is_err() {
388            #[cfg(feature = "tracing")]
389            tracing::debug!(
390                task_id,
391                timeout_secs = self.send_wait.as_secs(),
392                "send_message gave up waiting for the task to settle; returning it unsettled"
393            );
394        }
395
396        self.task_lifecycle.get(&id, None).await
397    }
398
399    /// Process a message and subscribe to its update stream.
400    ///
401    /// The update stream is started **before** the message is processed so no
402    /// early updates are missed. Returns the initial task and the stream; the
403    /// caller is responsible for emitting the initial task ahead of stream
404    /// items.
405    ///
406    /// The stream ends once the task settles (see [`until_settled`]), so a
407    /// caller that reads to completion is not left holding an open connection
408    /// to a finished task. A handler that settles the task before returning
409    /// never broadcasts such an event, so that case drains what is queued and
410    /// ends instead (see [`ready_queued`]).
411    pub async fn send_streaming_message(
412        &self,
413        message: Message,
414        ctx: &RequestContext,
415        push_config: Option<TaskPushNotificationConfig>,
416        history_limit: Option<u32>,
417    ) -> Result<(Task, UpdateStream), A2AError> {
418        let (id, context_id, message) = self.stamp_ids(message).await?;
419        let ctx = ctx.clone().with_session(context_id.as_str());
420        let task_id = id.as_str();
421
422        if let Some(mut push_config) = push_config {
423            push_config.task_id = task_id.to_string();
424            self.notification_manager
425                .set_validated(&push_config)
426                .await?;
427        }
428
429        // Start updates stream first so we don't miss early updates.
430        let update_stream = self
431            .streaming_handler
432            .start_task_streaming(task_id, None)
433            .await?;
434
435        let mut task = self
436            .message_handler
437            .process_message(task_id, &message, &ctx)
438            .await?;
439
440        if let Some(limit) = history_limit {
441            task = task.with_limited_history(Some(limit));
442        }
443
444        // A handler that answered synchronously has already settled the task,
445        // and nothing will broadcast for it again — the same case `subscribe`
446        // short-circuits. Waiting for a settling event here hangs the response.
447        let updates = if task.status.state.is_terminal() {
448            ready_queued(update_stream)
449        } else {
450            until_settled(update_stream)
451        };
452
453        Ok((task, updates))
454    }
455
456    /// Get a task by ID with optional history length limit.
457    pub async fn get(&self, id: &TaskId, history_length: Option<u32>) -> Result<Task, A2AError> {
458        self.task_lifecycle.get(id, history_length).await
459    }
460
461    /// List tasks with filtering and pagination.
462    pub async fn list(&self, params: &ListTasksParams) -> Result<ListTasksResult, A2AError> {
463        self.task_query.list(params).await
464    }
465
466    /// Cancel a task, then announce the terminal status to streaming
467    /// subscribers.
468    ///
469    /// Storage no longer self-broadcasts on cancellation (§4.0.2), so the
470    /// service owns the "commit then announce" step via the
471    /// [`TaskStatusBroadcast`] mixin it hosts.
472    pub async fn cancel(&self, id: &TaskId) -> Result<Task, A2AError> {
473        self.cancel_and_broadcast(id).await
474    }
475
476    /// Subscribe to a task's update stream, returning the current task (if it
477    /// exists) and the stream of subsequent updates.
478    ///
479    /// `from_event_id` carries a client's `Last-Event-ID` for resumption: when
480    /// set, the handler replays buffered events with a greater id before
481    /// streaming live updates.
482    ///
483    /// The stream ends once the task settles (see [`until_settled`]).
484    ///
485    /// Subscribing to a task that is *already* terminal is an
486    /// [`A2AError::UnsupportedOperation`], which is what `a2a.proto` specifies
487    /// for this call. An earlier version answered with the task's snapshot and
488    /// an empty stream; that reads on the wire as a subscription that opened
489    /// and closed with nothing to say, and a client cannot tell it apart from
490    /// an agent that has yet to speak. The error names the state instead.
491    ///
492    /// The check is conditional on `from_event_id` being unset, and that
493    /// condition is load-bearing: resuming after a disconnect on a task that
494    /// has since finished is precisely when the replay buffer matters — the
495    /// events the client missed are the ones it reconnected for. Refusing
496    /// because the task looks finished would turn resumption into an error on
497    /// exactly the call that exists to recover from one.
498    ///
499    /// A task already sitting in an interrupted state (`INPUT_REQUIRED`,
500    /// `AUTH_REQUIRED`) deliberately does **not** short-circuit: it resumes
501    /// under the same id once the caller supplies what it asked for, and a
502    /// subscriber that attached first is entitled to watch that happen. The
503    /// asymmetry with [`UpdateEvent::settles_task`] is the point — arriving at
504    /// an interrupted state ends a stream, finding one already there does not.
505    ///
506    /// [`UpdateEvent::settles_task`]: crate::port::UpdateEvent::settles_task
507    pub async fn subscribe(
508        &self,
509        task_id: &str,
510        from_event_id: Option<u64>,
511    ) -> Result<(Option<Task>, UpdateStream), A2AError> {
512        let id: TaskId = task_id.parse()?;
513
514        let initial_task = match self.task_lifecycle.get(&id, None).await {
515            Ok(task) => Some(task),
516            Err(A2AError::TaskNotFound(_)) => None,
517            Err(e) => return Err(e),
518        };
519
520        if from_event_id.is_none()
521            && let Some(task) = &initial_task
522            && task.status.state.is_terminal()
523        {
524            return Err(A2AError::UnsupportedOperation(format!(
525                "Task {} has already finished in state {:?}; subscribe is for tasks still running, use GetTask for its result",
526                task_id, task.status.state
527            )));
528        }
529
530        let update_stream = self
531            .streaming_handler
532            .start_task_streaming(task_id, from_event_id)
533            .await?;
534
535        Ok((initial_task, until_settled(update_stream)))
536    }
537
538    /// Create or replace a push-notification config (validated).
539    pub async fn set_push_config(
540        &self,
541        config: &TaskPushNotificationConfig,
542    ) -> Result<TaskPushNotificationConfig, A2AError> {
543        self.notification_manager.set_validated(config).await
544    }
545
546    /// Get a push-notification config for a task.
547    pub async fn get_push_config(
548        &self,
549        params: &GetTaskPushNotificationConfigParams,
550    ) -> Result<TaskPushNotificationConfig, A2AError> {
551        self.notification_manager.get_config(params).await
552    }
553
554    /// List push-notification configs for a task.
555    pub async fn list_push_configs(
556        &self,
557        params: &ListTaskPushNotificationConfigsParams,
558    ) -> Result<Vec<TaskPushNotificationConfig>, A2AError> {
559        self.notification_manager.list_configs(params).await
560    }
561
562    /// Delete a push-notification config.
563    pub async fn delete_push_config(
564        &self,
565        params: &DeleteTaskPushNotificationConfigParams,
566    ) -> Result<(), A2AError> {
567        self.notification_manager.delete_config(params).await
568    }
569
570    /// Fetch the authenticated extended agent card.
571    pub async fn extended_agent_card(&self) -> Result<AgentCard, A2AError> {
572        self.agent_info.get_authenticated_extended_card().await
573    }
574}
575
576// The service is the composed assembly holding both the lifecycle and streaming
577// ports, so it exposes them as mixin ingredients (see
578// `.claude/rules/hexagonal_architecture.md` §9). This grants it the
579// `TaskStatusBroadcast::update_and_broadcast` "commit then announce" capability
580// for free, without coupling either port to the other. The accessors return
581// `&dyn` **ports**, never the concrete adapters behind them.
582impl HasTaskLifecycle for TaskService {
583    fn lifecycle(&self) -> &dyn AsyncTaskLifecycle {
584        self.task_lifecycle.as_ref()
585    }
586}
587
588impl HasStreaming for TaskService {
589    fn streaming(&self) -> &dyn AsyncStreamingHandler {
590        self.streaming_handler.as_ref()
591    }
592}
593
594impl HasPushNotifier for TaskService {
595    fn push_notifier(&self) -> &dyn AsyncPushNotifier {
596        self.push_notifier.as_ref()
597    }
598}