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}