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}