Skip to main content

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;