a2a_rs/port/conversation_store.rs
1//! Durable conversation memory for a context.
2
3use async_trait::async_trait;
4
5use crate::domain::{A2AError, ContextId, Conversation, Digest};
6
7/// Reads and compacts the conversation recorded against one context.
8///
9/// Two methods, one capability. Splitting the tail and the digest into separate
10/// ports reads tidier and is wrong: a digest written between two reads leaves
11/// either a gap (messages the summary does not cover and the tail no longer
12/// includes) or duplicates, and nothing in two separate signatures says the
13/// reads have to agree. One method makes that boundary the implementation's
14/// problem, which is where it can actually be solved.
15///
16/// ## The caller argument
17///
18/// Every method takes the authenticated principal's id, or `None` where the
19/// agent runs without an authenticator. This is not ceremony: a `context_id`
20/// only groups tasks today, but a store that hands back a conversation turns it
21/// into a capability — whoever holds one can read what was said in it.
22/// Ownership is claimed on first write and enforced on every read, and a
23/// mismatch is [`A2AError::ContextAccessDenied`].
24#[async_trait]
25pub trait AsyncConversationStore: Send + Sync {
26 /// Load the newest digest and the messages recorded after its watermark.
27 ///
28 /// `limit` caps the tail, keeping the **newest** messages — a conversation
29 /// too long to load whole is more usefully truncated at its start, and the
30 /// part before it is what compaction summarizes. `None` loads everything
31 /// after the watermark.
32 ///
33 /// An unknown context is an empty [`Conversation`], not an error: the first
34 /// turn of a conversation asks for history that does not exist yet.
35 async fn load(
36 &self,
37 context_id: &ContextId,
38 caller: Option<&str>,
39 limit: Option<u32>,
40 ) -> Result<Conversation, A2AError>;
41
42 /// Append a digest covering everything through
43 /// [`Digest::covers_through`], claiming the context for `caller` if it is
44 /// new.
45 ///
46 /// Appends. Nothing is deleted and no earlier digest is replaced, so a
47 /// concurrent compaction of the same conversation costs duplicated work
48 /// rather than a lost or doubled transcript.
49 async fn compact(
50 &self,
51 context_id: &ContextId,
52 caller: Option<&str>,
53 digest: Digest,
54 ) -> Result<(), A2AError>;
55}
56
57/// Conveniences over [`AsyncConversationStore`].
58///
59/// Blanket-implemented, so they ride along on `Arc<dyn AsyncConversationStore>`
60/// too. `?Sized` is what makes that work.
61#[async_trait]
62pub trait AsyncConversationStoreExt: AsyncConversationStore {
63 /// Load a conversation, keeping at most `keep` of the most recent messages.
64 async fn load_recent(
65 &self,
66 context_id: &ContextId,
67 caller: Option<&str>,
68 keep: u32,
69 ) -> Result<Conversation, A2AError> {
70 self.load(context_id, caller, Some(keep)).await
71 }
72
73 /// Summarize everything loaded so far under one digest.
74 ///
75 /// Takes the watermark from the conversation itself, which is the common
76 /// case and the easy one to get wrong: a watermark computed from anything
77 /// other than what was actually summarized either re-summarizes messages or
78 /// drops them.
79 async fn compact_through(
80 &self,
81 context_id: &ContextId,
82 caller: Option<&str>,
83 conversation: &Conversation,
84 summary: String,
85 model: String,
86 ) -> Result<(), A2AError> {
87 let digest = Digest {
88 covers_through: conversation.watermark(),
89 summary,
90 replaced_messages: conversation.tail.len() as u32,
91 model,
92 };
93 self.compact(context_id, caller, digest).await
94 }
95}
96
97impl<T: AsyncConversationStore + ?Sized> AsyncConversationStoreExt for T {}
98
99/// A store that remembers nothing.
100///
101/// The adapter for `mode = "none"`: every load is an empty conversation and
102/// every compaction is a no-op. It exists so "this agent does not carry history"
103/// is a wired-up choice rather than an absent collaborator, which is what lets
104/// the handler take one code path either way.
105#[derive(Debug, Clone, Copy, Default)]
106pub struct NoConversationMemory;
107
108#[async_trait]
109impl AsyncConversationStore for NoConversationMemory {
110 async fn load(
111 &self,
112 _context_id: &ContextId,
113 _caller: Option<&str>,
114 _limit: Option<u32>,
115 ) -> Result<Conversation, A2AError> {
116 Ok(Conversation::default())
117 }
118
119 async fn compact(
120 &self,
121 _context_id: &ContextId,
122 _caller: Option<&str>,
123 _digest: Digest,
124 ) -> Result<(), A2AError> {
125 Ok(())
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use std::str::FromStr;
133
134 #[tokio::test]
135 async fn the_no_memory_store_reports_an_empty_conversation() {
136 let store = NoConversationMemory;
137 let context = ContextId::from_str("ctx-1").unwrap();
138
139 let conversation = store.load(&context, None, None).await.unwrap();
140 assert!(conversation.is_empty());
141
142 // And compacting it is not an error, so a handler needs no branch.
143 store
144 .compact_through(
145 &context,
146 None,
147 &conversation,
148 "nothing happened".to_string(),
149 "test".to_string(),
150 )
151 .await
152 .unwrap();
153 }
154}