lc_a2a/server/mod.rs
1//! A2A Server - handler functions for the Agent-to-Agent protocol.
2//!
3//! Provides `A2AServer` which holds an underlying agent (a `BaseChain`) and
4//! exposes handler functions that can be plugged into any HTTP framework
5//! (axum, actix, warp, etc.) rather than running its own server.
6//!
7//! # Endpoints
8//!
9//! - `GET /.well-known/agent-card.json` -> returns `AgentCard` (via `get_agent_card`)
10//! - `POST /` -> accepts `A2ARequest`, dispatches, returns `A2AResponse`
11//! (via `handle_a2a_request` / `handle_a2a_request_authenticated`)
12//!
13//! # Task Model
14//!
15//! `tasks/send` follows the A2A asynchronous task lifecycle. The request is
16//! acknowledged immediately with a `submitted` task and the chain runs in the
17//! background, transitioning the task `submitted -> working -> completed`
18//! (or `failed`). Poll `tasks/get` to observe progress. Every transition is
19//! guarded by the [`TaskStatus`] state machine, so a task cancelled while the
20//! chain is still running is never clobbered back to a live state.
21//!
22//! # Multi-turn & Input-Required (P2-2/P2-3)
23//!
24//! Re-sending `tasks/send` with a `taskId` appends a message to the existing
25//! task's history and re-runs the chain over the whole conversation. A chain
26//! that needs more information returns a `ChainError::MissingInput` /
27//! `ChainError::InputError`, which the server maps to the `input-required`
28//! state; the client then resumes with `tasks/send {taskId, message}`.
29//!
30//! # Ownership & Idempotency (P1-4/P1-6)
31//!
32//! Tasks carry an optional `owner` taken from request metadata. `tasks/get`
33//! and `tasks/cancel` from a caller whose metadata `owner` does not match the
34//! task's are rejected (`-32003`). A `message_id` in request metadata makes
35//! `tasks/send` idempotent: re-sending the same id returns the already
36//! created task instead of running the chain twice.
37//!
38//! # Task Persistence (P1-1)
39//!
40//! Tasks are stored through the [`TaskStore`] trait, defaulting to an
41//! in-memory [`InMemoryTaskStore`] shared with background workers. Swap in
42//! your own backend with [`A2AServer::with_store`]. Terminal tasks older than
43//! the configured TTL are cleaned up lazily on read access.
44//!
45//! # Streaming (P2-1)
46//!
47//! Enable [`A2AServer::with_streaming`] to get a `broadcast` channel of
48//! [`TaskPushNotification`]s (`subscribe()`), which an HTTP layer can expose
49//! as an SSE endpoint. The agent card then advertises `{"sse": true}`.
50//!
51//! # Skill routing (P2-4)
52//!
53//! [`A2AServer::with_skill_router`] dispatches `tasks/send` requests that
54//! carry a `skillId` param to a different chain based on the card's skills.
55//!
56//! # Example
57//!
58//! ```ignore
59//! use lc_a2a::{A2AServer, AgentCard};
60//! use lc_chains::LLMChain;
61//! use std::sync::Arc;
62//!
63//! let chain = Arc::new(LLMChain::new(llm, "You are a helpful assistant"));
64//! let server = A2AServer::new(chain)
65//! .with_card(AgentCard::new("my-agent", "A helpful agent", "http://localhost:8080"));
66//!
67//! // In your HTTP handler:
68//! let response = server.handle_a2a_request(request).await;
69//! ```
70
71mod execution;
72mod handlers;
73mod message;
74mod routes;
75
76use std::collections::{HashMap, HashSet, VecDeque};
77use std::sync::Arc;
78use std::time::Duration;
79
80use serde_json::{json, Value};
81use tokio::sync::{broadcast, RwLock};
82
83use lc_agents::AgentExecutor;
84use lc_chains::base::BaseChain;
85
86use super::agent_adapter::AgentExecutorChain;
87
88use super::protocol::{
89 A2AErrorData, A2AMessage, A2ARequest, A2AResponse, A2ATask, A2AWorkflow, AgentCard, AgentSkill,
90 TaskFilter, TaskPushNotification, TaskStatus,
91};
92use super::rate_limiter::RateLimiter;
93use super::router::{SkillMapRouter, SkillRouter};
94use super::store::{InMemoryTaskStore, StoredTask, TaskStore, DEFAULT_MAX_TASKS};
95use crate::client::signing::constant_time_eq;
96
97use execution::{run_task, run_workflow, sweep_expired_tasks, InflightResume, MAX_WORKFLOW_STEPS};
98use handlers::{forbidden, publish_status, task_details_response, task_not_found};
99use message::extract_message;
100
101/// Default task time-to-live before expiry cleanup (24 hours).
102const DEFAULT_TASK_TTL: Duration = Duration::from_secs(24 * 60 * 60);
103
104/// Maximum number of tracked idempotency keys before the oldest entry is
105/// evicted (0.22.0 audit fix H-P5: the `message_id` table used to grow
106/// unboundedly).
107const MAX_MESSAGE_IDS: usize = 10_000;
108
109/// Bounded insertion-ordered map backing the `message_id -> task_id`
110/// idempotency table (0.22.0 audit fix H-P5).
111///
112/// The `map` holds the reservation state (empty `task_id` = in-flight claim);
113/// `order` records insertion order so the oldest entry can be evicted when
114/// the table reaches [`MAX_MESSAGE_IDS`]. Aborted keys leave a stale entry in
115/// `order`, which eviction skips over.
116#[derive(Default)]
117struct MessageIdTable {
118 map: HashMap<String, String>,
119 order: VecDeque<String>,
120}
121
122impl MessageIdTable {
123 fn get(&self, mid: &str) -> Option<&String> {
124 self.map.get(mid)
125 }
126
127 fn insert(&mut self, mid: String, task_id: String) {
128 if !self.map.contains_key(&mid) {
129 // At capacity, pop-oldest and remove it from the map; skip
130 // entries that were already aborted.
131 while self.map.len() >= MAX_MESSAGE_IDS {
132 match self.order.pop_front() {
133 Some(oldest) if self.map.remove(&oldest).is_some() => break,
134 Some(_) => continue,
135 None => break,
136 }
137 }
138 self.order.push_back(mid.clone());
139 }
140 self.map.insert(mid, task_id);
141 }
142
143 fn remove(&mut self, mid: &str) {
144 self.map.remove(mid);
145 }
146}
147
148/// A2A Server - wraps an agent and provides handler functions.
149///
150/// The server does NOT start its own HTTP listener. Instead, it provides
151/// `handle_a2a_request()` and `get_agent_card()` that you can call from
152/// any HTTP framework's route handler.
153///
154/// Tasks are stored through the [`TaskStore`] trait so that `tasks/get` can
155/// retrieve them and `tasks/cancel` can transition their status. When the
156/// default in-memory store exceeds its capacity, the least recently updated
157/// task is evicted (LRU).
158pub struct A2AServer {
159 /// The underlying chain/agent.
160 chain: Arc<dyn BaseChain>,
161 /// The agent card metadata.
162 card: AgentCard,
163 /// Task persistence backend (P1-1).
164 store: Arc<dyn TaskStore>,
165 /// `message_id -> task_id` map for idempotent `tasks/send` (P1-6).
166 ///
167 /// A mapping whose value is the empty string marks a `message_id` claimed
168 /// by an in-flight request whose task has not been created yet; concurrent
169 /// retries with the same id see it and are rejected instead of
170 /// double-executing. The table is bounded (see [`MAX_MESSAGE_IDS`]).
171 message_ids: Arc<RwLock<MessageIdTable>>,
172 /// Task ids currently being resumed by `tasks/send_continue`.
173 ///
174 /// Guards the read-check-write of the resume path so two concurrent
175 /// resumes of the same `input-required` task cannot both pass the state
176 /// check and spawn racing workers (P2-3). A `std::sync::Mutex` suffices:
177 /// the critical section is a short contains+insert with no awaits.
178 inflight_resumes: Arc<std::sync::Mutex<HashSet<String>>>,
179 /// Optional skill -> chain router (P2-4).
180 skill_router: Option<Arc<dyn SkillRouter>>,
181 /// Optional SSE event bus (P2-1).
182 event_bus: Option<Arc<broadcast::Sender<TaskPushNotification>>>,
183 /// Expected bearer token for authenticated requests (None = auth disabled).
184 expected_token: Option<String>,
185 /// Optional rate limiter applied to every request.
186 rate_limiter: Option<Arc<RateLimiter>>,
187 /// Time-to-live for tasks before they expire.
188 task_ttl: Option<Duration>,
189}
190
191impl A2AServer {
192 /// Create a new A2A server backed by a `BaseChain`.
193 pub fn new(chain: Arc<dyn BaseChain>) -> Self {
194 let card = AgentCard::new(
195 chain.name(),
196 format!("Agent backed by {}", chain.name()),
197 "http://localhost:8080",
198 )
199 .with_skill(AgentSkill::new(
200 "default",
201 chain.name(),
202 format!("Agent backed by {}", chain.name()),
203 ));
204 Self {
205 chain,
206 card,
207 store: Arc::new(InMemoryTaskStore::with_max_tasks(DEFAULT_MAX_TASKS)),
208 message_ids: Arc::new(RwLock::new(MessageIdTable::default())),
209 inflight_resumes: Arc::new(std::sync::Mutex::new(HashSet::new())),
210 skill_router: None,
211 event_bus: None,
212 expected_token: None,
213 rate_limiter: None,
214 task_ttl: Some(DEFAULT_TASK_TTL),
215 }
216 }
217
218 /// Create a server backed directly by a stateful agent (P1-8).
219 ///
220 /// The [`AgentExecutor`] is adapted to the chain interface, so A2A tasks
221 /// get genuine conversational continuity. Attach memory to the executor
222 /// (`.with_memory(...)`) before wrapping for multi-turn state.
223 pub fn from_agent(executor: Arc<AgentExecutor>) -> Self {
224 Self::new(Arc::new(AgentExecutorChain::new(executor)))
225 }
226
227 /// Replace the default in-memory task store with a custom backend (P1-1).
228 pub fn with_store(mut self, store: Arc<dyn TaskStore>) -> Self {
229 self.store = store;
230 self
231 }
232
233 /// Set the maximum number of tasks before LRU eviction.
234 ///
235 /// Replaces the store with a fresh in-memory store of the given capacity,
236 /// discarding any tasks stored so far. Call this before sending tasks.
237 pub fn with_max_tasks(mut self, max: usize) -> Self {
238 self.store = Arc::new(InMemoryTaskStore::with_max_tasks(max.max(1)));
239 self
240 }
241
242 /// Attach a skill router so `tasks/send` requests with a `skillId` are
243 /// dispatched to a different chain (P2-4).
244 pub fn with_skill_router(mut self, router: Arc<dyn SkillRouter>) -> Self {
245 self.skill_router = Some(router);
246 self
247 }
248
249 /// Attach a default skill router built from a static `skill_id -> chain`
250 /// map (P2-4).
251 pub fn with_skill_map(mut self, map: SkillMapRouter) -> Self {
252 self.skill_router = Some(Arc::new(map));
253 self
254 }
255
256 /// Enable streaming push notifications over an SSE-compatible channel
257 /// (P2-1).
258 ///
259 /// Creates a `broadcast` channel with the given capacity and advertises
260 /// `{"sse": true}` on the agent card. Subscribe with
261 /// [`A2AServer::subscribe`].
262 pub fn with_streaming(mut self, capacity: usize) -> Self {
263 let (tx, _rx) = broadcast::channel(capacity.max(1));
264 self.event_bus = Some(Arc::new(tx));
265 self.card = self.card.clone().with_interfaces(json!({ "sse": true }));
266 self
267 }
268
269 /// Subscribe to task push notifications, if streaming is enabled (P2-1).
270 ///
271 /// Returns `None` when the server was not built with
272 /// [`A2AServer::with_streaming`].
273 pub fn subscribe(&self) -> Option<broadcast::Receiver<TaskPushNotification>> {
274 self.event_bus.as_ref().map(|tx| tx.subscribe())
275 }
276
277 /// Require a bearer token on every request.
278 ///
279 /// Enables authentication on the server and advertises `bearer` as a
280 /// supported scheme on the agent card. Requests without a matching
281 /// `Authorization: Bearer <token>` header are rejected with a 401.
282 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
283 self.expected_token = Some(token.into());
284 self.card = self
285 .card
286 .clone()
287 .with_authentication(vec!["bearer".to_string()]);
288 self
289 }
290
291 /// Attach a rate limiter applied to every incoming request.
292 pub fn with_rate_limiter(mut self, limiter: Arc<RateLimiter>) -> Self {
293 self.rate_limiter = Some(limiter);
294 self
295 }
296
297 /// Set the task time-to-live before expiry cleanup (`None` disables expiry).
298 pub fn with_task_ttl(mut self, ttl: Option<Duration>) -> Self {
299 self.task_ttl = ttl;
300 self
301 }
302
303 /// Spawn a background sweeper that periodically scans for expired tasks
304 /// (P1-2), in addition to the lazy cleanup on the read paths.
305 ///
306 /// The loop calls `sweep_expired_tasks` every `interval` (clamped to at
307 /// least 1s). It runs until the current Tokio runtime shuts down. If the
308 /// server has no TTL configured (`with_task_ttl(None)`), no task is
309 /// spawned — there is nothing to expire.
310 pub fn with_background_cleanup(self, interval: Duration) -> Self {
311 let Some(ttl) = self.task_ttl else {
312 return self;
313 };
314 let store = self.store.clone();
315 // Clamp away a zero interval (which `tokio::time::interval` rejects);
316 // sub-second intervals are allowed so tests can drive the sweeper fast.
317 let interval = interval.max(Duration::from_millis(1));
318 tokio::spawn(async move {
319 let mut ticker = tokio::time::interval(interval);
320 // The first tick completes immediately; consume it so the first
321 // sweep happens after one full interval.
322 ticker.tick().await;
323 loop {
324 ticker.tick().await;
325 sweep_expired_tasks(&store, ttl).await;
326 }
327 });
328 self
329 }
330
331 /// Set a custom agent card.
332 pub fn with_card(mut self, card: AgentCard) -> Self {
333 self.card = card;
334 self
335 }
336
337 /// Get the agent card (for `GET /.well-known/agent-card.json`).
338 pub fn get_agent_card(&self) -> &AgentCard {
339 &self.card
340 }
341
342 /// Handle an incoming A2A request (for `POST /`).
343 ///
344 /// Applies the optional rate limiter, then dispatches based on the
345 /// request method:
346 /// - `tasks/send` -> acknowledge a new async task (or continue one)
347 /// - `tasks/get` -> return a stored task
348 /// - `tasks/cancel` -> cancel a stored task
349 /// - `tasks/list` -> list stored tasks
350 /// - unknown method -> method_not_found error
351 pub async fn handle_a2a_request(&self, req: A2ARequest) -> A2AResponse {
352 // 0.22.0 audit fix (H-P3): bind the permit in the enclosing scope so
353 // it is held across the dispatch await. Holding it in a temporary
354 // `if let` value dropped it at the end of the statement, before the
355 // dispatch ran, so the concurrency cap never applied.
356 let _permit = match &self.rate_limiter {
357 Some(limiter) => match limiter.try_acquire().await {
358 Ok(permit) => Some(permit),
359 Err(e) => return A2AResponse::error(req.id, 429, e.to_string()),
360 },
361 None => None,
362 };
363 self.dispatch(req).await
364 }
365
366 /// Handle an incoming request with an optional bearer token.
367 ///
368 /// If the server was configured with [`A2AServer::with_auth_token`],
369 /// requests without a matching bearer token are rejected with a 401.
370 pub async fn handle_a2a_request_authenticated(
371 &self,
372 req: A2ARequest,
373 bearer: Option<&str>,
374 ) -> A2AResponse {
375 if let Err(resp) = self.check_auth(bearer) {
376 return resp;
377 }
378 self.handle_a2a_request(req).await
379 }
380
381 /// Validate the bearer token if the server requires one.
382 ///
383 /// Returns `Ok(())` when no token is configured or the token matches;
384 /// otherwise `Err` carries the 401 [`A2AResponse`] to return to the caller.
385 ///
386 /// Shared by the JSON-RPC handler and the SSE streaming endpoint so a
387 /// `with_auth_token` server cannot be bypassed by connecting to `/events`
388 /// directly (0.20.0 S4 G1).
389 pub(crate) fn check_auth(&self, bearer: Option<&str>) -> Result<(), A2AResponse> {
390 if let Some(expected) = &self.expected_token {
391 match bearer {
392 None => return Err(A2AResponse::error(0, 401, "Authentication required")),
393 // 0.22.0 audit fix: compare in constant time so the check does
394 // not leak the expected token through early-exit timing.
395 Some(token) if !constant_time_eq(token, expected) => {
396 return Err(A2AResponse::error(0, 401, "Invalid authentication token"));
397 }
398 Some(_) => {}
399 }
400 }
401 Ok(())
402 }
403
404 /// Dispatch a request to the matching handler.
405 ///
406 /// Requests carrying a W3C-style `trace_id` in metadata are logged so a
407 /// distributed trace can be followed across agents (P1-5).
408 async fn dispatch(&self, req: A2ARequest) -> A2AResponse {
409 if let Some(trace_id) = req.trace_id() {
410 log::debug!(
411 "a2a request method={} id={} trace_id={}",
412 req.method,
413 req.id,
414 trace_id
415 );
416 }
417 match req.method.as_str() {
418 "tasks/send" => self.handle_tasks_send(req).await,
419 "tasks/get" => self.handle_tasks_get(req).await,
420 "tasks/cancel" => self.handle_tasks_cancel(req).await,
421 "tasks/list" => self.handle_tasks_list(req).await,
422 "tasks/runWorkflow" => self.handle_workflow_run(req).await,
423 _ => A2AResponse::from_error_data(req.id, A2AErrorData::method_not_found()),
424 }
425 }
426
427 /// Whether `req` may access a task with `owner`-based protection (P1-4).
428 ///
429 /// Tasks without an `owner` are open to any caller; tasks with an `owner`
430 /// are only accessible to a caller whose metadata `owner` matches exactly.
431 fn caller_owns(&self, req: &A2ARequest, task: &A2ATask) -> bool {
432 match &task.owner {
433 Some(task_owner) => req.owner() == Some(task_owner.as_str()),
434 None => true,
435 }
436 }
437
438 /// Resolve the chain for a skill id, falling back to the default chain
439 /// (P2-4).
440 fn resolve_chain(&self, skill_id: Option<&str>) -> Arc<dyn BaseChain> {
441 if let Some(sid) = skill_id {
442 if let Some(router) = &self.skill_router {
443 if let Some(chain) = router.chain_for(sid) {
444 return chain;
445 }
446 }
447 }
448 self.chain.clone()
449 }
450
451 /// Reserve a `message_id` for an idempotent `tasks/send` (P1-6).
452 ///
453 /// The reservation makes the check-then-act atomic: only the caller that
454 /// wins the claim may create the task, so two concurrent retries with the
455 /// same `message_id` cannot both run the chain.
456 ///
457 /// Returns:
458 /// - `Ok(Some(task_id))`: a prior send with this `message_id` completed
459 /// and its task still exists.
460 /// - `Ok(None)`: this caller won the reservation; it must create the task
461 /// and then call [`Self::finish_message_id`], or [`Self::abort_message_id`]
462 /// if creation fails.
463 /// - `Err(())`: another request with the same `message_id` is being
464 /// processed right now; the caller should return a retryable error.
465 async fn reserve_message_id(&self, mid: &str) -> Result<Option<String>, ()> {
466 // Read the current mapping under a short lock; never await inside it.
467 let mapped = { self.message_ids.read().await.get(mid).cloned() };
468 if let Some(task_id) = mapped {
469 if !task_id.is_empty() {
470 return match self.store.get(&task_id).await {
471 Ok(Some(_)) => Ok(Some(task_id)),
472 // Referenced task evicted/expired: reclaim the id.
473 _ => self.claim_message_id(mid).await,
474 };
475 }
476 // In-flight reservation by another request.
477 return Err(());
478 }
479 self.claim_message_id(mid).await
480 }
481
482 /// Atomically claim `mid`, inserting an in-flight marker.
483 async fn claim_message_id(&self, mid: &str) -> Result<Option<String>, ()> {
484 let mut guard = self.message_ids.write().await;
485 match guard.get(mid).cloned() {
486 Some(task_id) if !task_id.is_empty() => Ok(Some(task_id)), // finished concurrently
487 Some(_) => Err(()), // claimed concurrently
488 None => {
489 guard.insert(mid.to_string(), String::new());
490 Ok(None)
491 }
492 }
493 }
494
495 /// Record that a send carrying `mid` created task `task_id`.
496 async fn finish_message_id(&self, mid: &str, task_id: &str) {
497 self.message_ids
498 .write()
499 .await
500 .insert(mid.to_string(), task_id.to_string());
501 }
502
503 /// Release an unused `message_id` reservation (task creation failed).
504 async fn abort_message_id(&self, mid: &str) {
505 self.message_ids.write().await.remove(mid);
506 }
507
508 /// Release a `message_id` reservation held by a continuation that failed
509 /// before completing, so a retry can claim it again.
510 async fn release_resume_id(&self, message_id: &Option<String>) {
511 if let Some(mid) = message_id {
512 self.abort_message_id(mid).await;
513 }
514 }
515
516 /// Claim a task id for an in-flight resume (P2-3).
517 ///
518 /// Returns `None` if the task is already being resumed by another request.
519 /// The returned guard releases the claim on drop, covering every exit path
520 /// (early returns included).
521 fn begin_resume(&self, task_id: &str) -> Option<InflightResume> {
522 let mut guard = self
523 .inflight_resumes
524 .lock()
525 .unwrap_or_else(|e| e.into_inner());
526 if guard.contains(task_id) {
527 return None;
528 }
529 guard.insert(task_id.to_string());
530 Some(InflightResume {
531 inner: self.inflight_resumes.clone(),
532 task_id: task_id.to_string(),
533 })
534 }
535
536 /// Lazily expire tasks older than the configured TTL.
537 ///
538 /// Terminal tasks older than the TTL are removed to bound memory; live
539 /// tasks older than the TTL are transitioned to `expired`.
540 async fn cleanup_expired_tasks(&self) {
541 let Some(ttl) = self.task_ttl else {
542 return;
543 };
544 sweep_expired_tasks(&self.store, ttl).await;
545 }
546}
547
548#[cfg(test)]
549mod tests;