Skip to main content

a2a_server/task_store/
inmemory.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3use a2a::*;
4use async_trait::async_trait;
5use std::collections::HashMap;
6use tokio::sync::RwLock;
7
8use super::apply_history_length;
9use super::store::{TaskStore, TaskVersion};
10use crate::pagination::resolve_page_size;
11
12struct StoredEntry {
13    task: Task,
14    version: TaskVersion,
15}
16
17/// In-memory task store. Contents do not survive restarts.
18pub struct InMemoryTaskStore {
19    tasks: RwLock<HashMap<TaskId, StoredEntry>>,
20}
21
22impl InMemoryTaskStore {
23    pub fn new() -> Self {
24        InMemoryTaskStore {
25            tasks: RwLock::new(HashMap::new()),
26        }
27    }
28}
29
30impl Default for InMemoryTaskStore {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36#[async_trait]
37impl TaskStore for InMemoryTaskStore {
38    async fn create(&self, task: Task) -> Result<TaskVersion, A2AError> {
39        let mut store = self.tasks.write().await;
40        if store.contains_key(&task.id) {
41            return Err(A2AError::internal("task already exists"));
42        }
43        let id = task.id.clone();
44        store.insert(id, StoredEntry { task, version: 1 });
45        Ok(1)
46    }
47
48    async fn update(&self, task: Task) -> Result<TaskVersion, A2AError> {
49        let mut store = self.tasks.write().await;
50        let entry = store
51            .get_mut(&task.id)
52            .ok_or_else(|| A2AError::task_not_found(&task.id))?;
53        entry.version += 1;
54        entry.task = task;
55        Ok(entry.version)
56    }
57
58    async fn get(&self, task_id: &str) -> Result<Option<Task>, A2AError> {
59        let store = self.tasks.read().await;
60        Ok(store.get(task_id).map(|e| e.task.clone()))
61    }
62
63    async fn list(&self, req: &ListTasksRequest) -> Result<ListTasksResponse, A2AError> {
64        let store = self.tasks.read().await;
65        let mut tasks: Vec<Task> = store
66            .values()
67            .filter(|entry| {
68                // Filter by context_id if provided
69                if let Some(ref ctx_id) = req.context_id {
70                    if entry.task.context_id != *ctx_id {
71                        return false;
72                    }
73                }
74                // Filter by status if provided
75                if let Some(ref status) = req.status {
76                    if entry.task.status.state != *status {
77                        return false;
78                    }
79                }
80                true
81            })
82            .map(|e| e.task.clone())
83            .collect();
84
85        // Sort by ID for deterministic output
86        tasks.sort_by(|a, b| a.id.cmp(&b.id));
87
88        // Apply pagination
89        // Defence in depth: DefaultRequestHandler clamps before calling a
90        // store, but a store reached by another route must still bound its
91        // own page.
92        let page_size = resolve_page_size(req.page_size);
93        let start = if let Some(ref token) = req.page_token {
94            // Simple offset-based pagination
95            token
96                .parse::<usize>()
97                .map_err(|_| A2AError::invalid_params("invalid page token"))?
98        } else {
99            0
100        };
101
102        let total_size = tasks.len();
103        // Make sure start is within bounds
104        let start = start.min(total_size);
105        let end = start.saturating_add(page_size).min(total_size);
106        let page = tasks[start..end].to_vec();
107
108        let next_page_token = if end < total_size {
109            Some(end.to_string())
110        } else {
111            None
112        };
113
114        let page = page
115            .into_iter()
116            .map(|mut task| {
117                apply_history_length(&mut task, req.history_length);
118                task
119            })
120            .collect();
121
122        Ok(ListTasksResponse {
123            tasks: page,
124            next_page_token: next_page_token.unwrap_or_default(),
125            page_size: page_size as i32,
126            total_size: total_size as i32,
127        })
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn make_task(id: &str, ctx: &str, state: TaskState) -> Task {
136        Task {
137            id: id.to_string(),
138            context_id: ctx.to_string(),
139            status: TaskStatus {
140                state,
141                message: None,
142                timestamp: None,
143            },
144            artifacts: None,
145            history: None,
146            metadata: None,
147        }
148    }
149
150    #[tokio::test]
151    async fn test_create_and_get() {
152        let store = InMemoryTaskStore::new();
153        let task = make_task("t1", "c1", TaskState::Submitted);
154        let ver = store.create(task.clone()).await.unwrap();
155        assert_eq!(ver, 1);
156
157        let got = store.get("t1").await.unwrap().unwrap();
158        assert_eq!(got.id, "t1");
159        assert_eq!(got.context_id, "c1");
160    }
161
162    #[tokio::test]
163    async fn test_create_duplicate() {
164        let store = InMemoryTaskStore::new();
165        let task = make_task("t1", "c1", TaskState::Submitted);
166        store.create(task.clone()).await.unwrap();
167
168        let result = store.create(task).await;
169        assert!(result.is_err());
170    }
171
172    #[tokio::test]
173    async fn test_update() {
174        let store = InMemoryTaskStore::new();
175        let task = make_task("t1", "c1", TaskState::Submitted);
176        store.create(task).await.unwrap();
177
178        let updated = make_task("t1", "c1", TaskState::Working);
179        let ver = store.update(updated).await.unwrap();
180        assert_eq!(ver, 2);
181
182        let got = store.get("t1").await.unwrap().unwrap();
183        assert_eq!(got.status.state, TaskState::Working);
184    }
185
186    #[tokio::test]
187    async fn test_update_not_found() {
188        let store = InMemoryTaskStore::new();
189        let task = make_task("t1", "c1", TaskState::Working);
190        let result = store.update(task).await;
191        assert!(result.is_err());
192    }
193
194    #[tokio::test]
195    async fn test_get_not_found() {
196        let store = InMemoryTaskStore::new();
197        let got = store.get("nonexistent").await.unwrap();
198        assert!(got.is_none());
199    }
200
201    #[tokio::test]
202    async fn test_list_filter_by_context() {
203        let store = InMemoryTaskStore::new();
204        store
205            .create(make_task("t1", "c1", TaskState::Submitted))
206            .await
207            .unwrap();
208        store
209            .create(make_task("t2", "c2", TaskState::Working))
210            .await
211            .unwrap();
212        store
213            .create(make_task("t3", "c1", TaskState::Completed))
214            .await
215            .unwrap();
216
217        let req = ListTasksRequest {
218            context_id: Some("c1".to_string()),
219            status: None,
220            page_size: None,
221            page_token: None,
222            history_length: None,
223            status_timestamp_after: None,
224            include_artifacts: None,
225            tenant: None,
226        };
227        let resp = store.list(&req).await.unwrap();
228        assert_eq!(resp.tasks.len(), 2);
229        assert_eq!(resp.total_size, 2);
230    }
231
232    #[tokio::test]
233    async fn test_list_filter_by_status() {
234        let store = InMemoryTaskStore::new();
235        store
236            .create(make_task("t1", "c1", TaskState::Submitted))
237            .await
238            .unwrap();
239        store
240            .create(make_task("t2", "c1", TaskState::Working))
241            .await
242            .unwrap();
243
244        let req = ListTasksRequest {
245            context_id: None,
246            status: Some(TaskState::Working),
247            page_size: None,
248            page_token: None,
249            history_length: None,
250            status_timestamp_after: None,
251            include_artifacts: None,
252            tenant: None,
253        };
254        let resp = store.list(&req).await.unwrap();
255        assert_eq!(resp.tasks.len(), 1);
256        assert_eq!(resp.tasks[0].id, "t2");
257    }
258
259    #[tokio::test]
260    async fn test_list_pagination() {
261        let store = InMemoryTaskStore::new();
262        for i in 0..5 {
263            store
264                .create(make_task(&format!("t{i}"), "c1", TaskState::Submitted))
265                .await
266                .unwrap();
267        }
268
269        let req = ListTasksRequest {
270            context_id: None,
271            status: None,
272            page_size: Some(2),
273            page_token: None,
274            history_length: None,
275            status_timestamp_after: None,
276            include_artifacts: None,
277            tenant: None,
278        };
279        let resp = store.list(&req).await.unwrap();
280        assert_eq!(resp.tasks.len(), 2);
281        assert_eq!(resp.total_size, 5);
282        assert!(!resp.next_page_token.is_empty());
283
284        // Get second page
285        let req2 = ListTasksRequest {
286            page_token: Some(resp.next_page_token),
287            page_size: Some(2),
288            ..req
289        };
290        let resp2 = store.list(&req2).await.unwrap();
291        assert_eq!(resp2.tasks.len(), 2);
292    }
293
294    #[tokio::test]
295    async fn test_list_zero_page_size_uses_default_window() {
296        let store = InMemoryTaskStore::new();
297        for i in 0..3 {
298            store
299                .create(make_task(&format!("t{i}"), "c1", TaskState::Submitted))
300                .await
301                .unwrap();
302        }
303
304        let req = ListTasksRequest {
305            context_id: None,
306            status: None,
307            page_size: Some(0),
308            page_token: None,
309            history_length: None,
310            status_timestamp_after: None,
311            include_artifacts: None,
312            tenant: None,
313        };
314        let resp = store.list(&req).await.unwrap();
315        assert_eq!(resp.tasks.len(), 3);
316        assert_eq!(resp.page_size, 50);
317    }
318
319    #[tokio::test]
320    async fn test_list_history_truncation() {
321        let store = InMemoryTaskStore::new();
322        let mut task = make_task("t1", "c1", TaskState::Working);
323        task.history = Some(vec![
324            Message::new(Role::User, vec![Part::text("1")]),
325            Message::new(Role::Agent, vec![Part::text("2")]),
326            Message::new(Role::User, vec![Part::text("3")]),
327        ]);
328        store.create(task).await.unwrap();
329
330        let req = ListTasksRequest {
331            context_id: None,
332            status: None,
333            page_size: None,
334            page_token: None,
335            history_length: Some(1),
336            status_timestamp_after: None,
337            include_artifacts: None,
338            tenant: None,
339        };
340        let resp = store.list(&req).await.unwrap();
341        assert_eq!(resp.tasks[0].history.as_ref().unwrap().len(), 1);
342
343        let empty = store
344            .list(&ListTasksRequest {
345                history_length: Some(-1),
346                ..req
347            })
348            .await
349            .unwrap();
350        assert!(empty.tasks[0].history.as_ref().unwrap().is_empty());
351    }
352
353    fn make_task_with_history(id: &str, messages: Vec<&str>) -> Task {
354        let mut task = make_task(id, "c1", TaskState::Working);
355        task.history = Some(
356            messages
357                .into_iter()
358                .map(|m| Message::new(Role::User, vec![Part::text(m)]))
359                .collect(),
360        );
361        task
362    }
363
364    #[tokio::test]
365    async fn test_list_negative_history_length_returns_empty_history() {
366        let store = InMemoryTaskStore::new();
367        store
368            .create(make_task_with_history("t1", vec!["1", "2", "3"]))
369            .await
370            .unwrap();
371
372        let req = ListTasksRequest {
373            context_id: None,
374            status: None,
375            page_size: None,
376            page_token: None,
377            history_length: Some(-1),
378            status_timestamp_after: None,
379            include_artifacts: None,
380            tenant: None,
381        };
382        let resp = store.list(&req).await.unwrap();
383        assert_eq!(resp.tasks.len(), 1);
384        assert_eq!(resp.tasks[0].history.as_ref().unwrap().len(), 0);
385    }
386
387    #[tokio::test]
388    async fn test_list_page_size_is_capped() {
389        let store = InMemoryTaskStore::new();
390        for i in 0..150 {
391            store
392                .create(make_task(&format!("t{i:03}"), "c1", TaskState::Submitted))
393                .await
394                .unwrap();
395        }
396
397        let req = ListTasksRequest {
398            context_id: None,
399            status: None,
400            page_size: Some(1000),
401            page_token: None,
402            history_length: None,
403            status_timestamp_after: None,
404            include_artifacts: None,
405            tenant: None,
406        };
407        let resp = store.list(&req).await.unwrap();
408        assert_eq!(resp.tasks.len(), 100);
409        assert_eq!(resp.page_size, 100);
410        assert_eq!(resp.total_size, 150);
411    }
412
413    #[tokio::test]
414    async fn test_list_invalid_page_token_errors() {
415        let store = InMemoryTaskStore::new();
416        store
417            .create(make_task("t1", "c1", TaskState::Submitted))
418            .await
419            .unwrap();
420
421        let req = ListTasksRequest {
422            context_id: None,
423            status: None,
424            page_size: None,
425            page_token: Some("not-a-number".into()),
426            history_length: None,
427            status_timestamp_after: None,
428            include_artifacts: None,
429            tenant: None,
430        };
431        let result = store.list(&req).await;
432        assert!(result.is_err());
433        assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
434    }
435
436    #[tokio::test]
437    async fn test_list_out_of_range_page_token_returns_empty_page() {
438        let store = InMemoryTaskStore::new();
439        for i in 0..3 {
440            store
441                .create(make_task(&format!("t{i}"), "c1", TaskState::Submitted))
442                .await
443                .unwrap();
444        }
445
446        let req = ListTasksRequest {
447            context_id: None,
448            status: None,
449            page_size: None,
450            page_token: Some("999".to_string()),
451            history_length: None,
452            status_timestamp_after: None,
453            include_artifacts: None,
454            tenant: None,
455        };
456        let resp = store.list(&req).await.unwrap();
457
458        assert!(resp.tasks.is_empty());
459        assert!(
460            resp.next_page_token.is_empty(),
461            "must not hand back another token"
462        );
463        assert_eq!(resp.total_size, 3);
464    }
465}