Skip to main content

a2a_rs/domain/
state.rs

1//! What an agent remembers about a context, apart from what was said in it.
2//!
3//! A conversation is the record of the turns; this is the small set of facts an
4//! agent was asked to keep — the user's name, a unit preference, a project it is
5//! working on. Two things make it worth having next to the transcript:
6//! compaction rewrites the transcript and a stored value survives it, and a
7//! `user:` value is readable from a context the caller has not opened yet.
8//!
9//! Pure data. Reading and writing are the [`AsyncContextStateStore`] port's job,
10//! and how it is worded to a model belongs to whoever builds the prompt.
11//!
12//! [`AsyncContextStateStore`]: crate::port::AsyncContextStateStore
13
14use std::collections::BTreeMap;
15use std::fmt;
16use std::str::FromStr;
17
18/// Marks a key kept against the principal rather than the context.
19const USER_PREFIX: &str = "user:";
20/// Marks a key that is deliberately not stored.
21const TEMP_PREFIX: &str = "temp:";
22
23/// How long a remembered value lives, taken from the key's prefix.
24///
25/// The prefix is part of the key the model writes and reads back, so the scope
26/// is visible wherever the key is — in the prompt, in a `forget` call, and in
27/// the store. Taken from Google ADK, which spells the same three this way.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum StateScope {
30    /// `user:` — filed under the authenticated principal, so every context that
31    /// principal opens reads it back.
32    ///
33    /// Requires an authenticator. With no principal there is nothing to file it
34    /// under, and storing it against the context instead would promise a
35    /// lifetime it does not have.
36    User,
37    /// No prefix — filed under this context, and read back only here.
38    Context,
39    /// `temp:` — not stored anywhere.
40    ///
41    /// It exists so the prefix means something: without it, `temp:draft` would
42    /// be an ordinary key that outlives the turn under a name saying it does
43    /// not.
44    Temp,
45}
46
47impl StateScope {
48    /// The prefix a key in this scope carries.
49    pub fn prefix(self) -> &'static str {
50        match self {
51            Self::User => USER_PREFIX,
52            Self::Context => "",
53            Self::Temp => TEMP_PREFIX,
54        }
55    }
56
57    /// Whether a store ever sees a key in this scope.
58    pub fn is_stored(self) -> bool {
59        !matches!(self, Self::Temp)
60    }
61}
62
63/// Why a string is not usable as a state key.
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65pub enum StateKeyError {
66    #[error("a state key cannot be empty")]
67    Empty,
68
69    /// A prefix that looks like a scope and is not one.
70    ///
71    /// Refused rather than read as part of the name: `app:tone` treated as an
72    /// ordinary key is stored under a name that says it is scoped to the
73    /// application and is not, which is the mistake the prefixes exist to make
74    /// visible.
75    #[error(
76        "'{prefix}:' is not a memory scope — write `user:{name}` for something that outlives \
77         this conversation, `temp:{name}` for something that is not stored, or `{name}` on its own"
78    )]
79    UnknownScope { prefix: String, name: String },
80
81    #[error("a state key cannot contain control characters or newlines")]
82    ControlCharacter,
83
84    #[error("a state key is at most {max} characters, got {len}")]
85    TooLong { len: usize, max: usize },
86}
87
88/// A key into the state bag, with the scope its prefix names.
89///
90/// Parsed once, at the edge, so everything downstream holds a key whose scope is
91/// already decided.
92#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
93pub struct StateKey {
94    scope: StateScope,
95    name: String,
96}
97
98impl StateKey {
99    /// Longest a key may be, prefix included. Keys are rendered into every
100    /// request, and a key long enough to matter is a value wearing the wrong
101    /// shape.
102    pub const MAX_LEN: usize = 128;
103
104    /// Build a key from a scope and the name a store filed it under.
105    ///
106    /// The other way round from [`FromStr`]: a store keeps the scope in its own
107    /// column and the name without its prefix, so reading one back has both
108    /// halves already and nothing to parse.
109    pub fn scoped(scope: StateScope, name: &str) -> Result<Self, StateKeyError> {
110        let name = name.trim();
111        if name.is_empty() {
112            return Err(StateKeyError::Empty);
113        }
114        let len = scope.prefix().chars().count() + name.chars().count();
115        if len > Self::MAX_LEN {
116            return Err(StateKeyError::TooLong {
117                len,
118                max: Self::MAX_LEN,
119            });
120        }
121        if name.chars().any(char::is_control) {
122            return Err(StateKeyError::ControlCharacter);
123        }
124        Ok(Self {
125            scope,
126            name: name.to_string(),
127        })
128    }
129
130    /// The scope this key is stored in.
131    pub fn scope(&self) -> StateScope {
132        self.scope
133    }
134
135    /// The key without its scope prefix, which is what a store files it under.
136    pub fn name(&self) -> &str {
137        &self.name
138    }
139}
140
141impl FromStr for StateKey {
142    type Err = StateKeyError;
143
144    fn from_str(raw: &str) -> Result<Self, Self::Err> {
145        let raw = raw.trim();
146        if raw.is_empty() {
147            return Err(StateKeyError::Empty);
148        }
149        let len = raw.chars().count();
150        if len > Self::MAX_LEN {
151            return Err(StateKeyError::TooLong {
152                len,
153                max: Self::MAX_LEN,
154            });
155        }
156        if raw.chars().any(char::is_control) {
157            return Err(StateKeyError::ControlCharacter);
158        }
159
160        let (scope, name) = if let Some(name) = raw.strip_prefix(USER_PREFIX) {
161            (StateScope::User, name)
162        } else if let Some(name) = raw.strip_prefix(TEMP_PREFIX) {
163            (StateScope::Temp, name)
164        } else if let Some((prefix, name)) = raw.split_once(':') {
165            return Err(StateKeyError::UnknownScope {
166                prefix: prefix.to_string(),
167                name: name.to_string(),
168            });
169        } else {
170            (StateScope::Context, raw)
171        };
172
173        Self::scoped(scope, name)
174    }
175}
176
177impl fmt::Display for StateKey {
178    /// The key as written and as read back, prefix included.
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        write!(f, "{}{}", self.scope.prefix(), self.name)
181    }
182}
183
184/// Everything an agent remembers that is visible from one context: the
185/// context's own keys and the caller's.
186///
187/// Ordered by scope and then by name, so the same set of facts renders the same
188/// way on every turn. A prompt prefix that reorders between requests is one a
189/// provider cannot serve from cache.
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct ContextState {
192    entries: BTreeMap<StateKey, String>,
193}
194
195impl ContextState {
196    /// Nothing remembered.
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// Record `value` under `key`, replacing whatever it held.
202    pub fn insert(&mut self, key: StateKey, value: impl Into<String>) {
203        self.entries.insert(key, value.into());
204    }
205
206    /// What `key` holds, if anything.
207    pub fn get(&self, key: &StateKey) -> Option<&str> {
208        self.entries.get(key).map(String::as_str)
209    }
210
211    /// Drop `key`, reporting whether it held anything.
212    pub fn remove(&mut self, key: &StateKey) -> bool {
213        self.entries.remove(key).is_some()
214    }
215
216    /// Every key and value, ordered by scope and then name.
217    pub fn iter(&self) -> impl Iterator<Item = (&StateKey, &str)> {
218        self.entries
219            .iter()
220            .map(|(key, value)| (key, value.as_str()))
221    }
222
223    /// How many keys are held.
224    pub fn len(&self) -> usize {
225        self.entries.len()
226    }
227
228    /// Whether anything is remembered at all.
229    pub fn is_empty(&self) -> bool {
230        self.entries.is_empty()
231    }
232}
233
234/// What a [`remember`] did to the key it was given.
235///
236/// The same argument the `bool` from [`forget`] carries: "there was nothing
237/// there" and "something else was" are different answers to give whoever is
238/// talking to the agent. A value replaced in place otherwise leaves no trace —
239/// an agent that overwrites `user:name` with the wrong thing loses what it held
240/// and nothing can say so.
241///
242/// [`remember`]: crate::port::AsyncContextStateStore::remember
243/// [`forget`]: crate::port::AsyncContextStateStore::forget
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum Remembered {
246    /// The key held nothing, and now holds this value.
247    Stored,
248    /// The key already held exactly this value.
249    ///
250    /// Apart from [`Stored`](Self::Stored) because an agent re-asserting a fact
251    /// it was told several turns ago is not overwriting anything, and reporting
252    /// that as a replacement would make the common case wear the shape of the
253    /// one worth noticing.
254    Unchanged,
255    /// The key held something else, and this call replaced it.
256    Replaced {
257        /// What the key held before. Nothing else records it: the row is
258        /// overwritten in place.
259        previous: String,
260    },
261    /// Nothing was stored — a [`StateScope::Temp`] key, or a store that keeps
262    /// nothing.
263    ///
264    /// Not folded into [`Stored`](Self::Stored): a caller that reads back
265    /// `temp:draft` and finds nothing is owed an answer saying why, rather than
266    /// one that claimed the write landed.
267    NotStored,
268}
269
270impl Remembered {
271    /// What this call overwrote, if it overwrote anything.
272    pub fn replaced_value(&self) -> Option<&str> {
273        match self {
274            Self::Replaced { previous } => Some(previous),
275            _ => None,
276        }
277    }
278}
279
280impl FromIterator<(StateKey, String)> for ContextState {
281    fn from_iter<I: IntoIterator<Item = (StateKey, String)>>(iter: I) -> Self {
282        Self {
283            entries: iter.into_iter().collect(),
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn key(raw: &str) -> StateKey {
293        raw.parse().unwrap()
294    }
295
296    #[test]
297    fn a_bare_key_is_scoped_to_the_context() {
298        let parsed = key("project");
299        assert_eq!(parsed.scope(), StateScope::Context);
300        assert_eq!(parsed.name(), "project");
301        assert_eq!(parsed.to_string(), "project");
302    }
303
304    #[test]
305    fn the_prefixes_name_the_other_two_scopes() {
306        assert_eq!(key("user:tone").scope(), StateScope::User);
307        assert_eq!(key("user:tone").name(), "tone");
308        assert_eq!(key("temp:draft").scope(), StateScope::Temp);
309        // And a key round-trips through its rendering, which is what a `forget`
310        // call is given back.
311        assert_eq!(key("user:tone").to_string(), "user:tone");
312    }
313
314    /// The scope has to survive the round trip through a store, which files a
315    /// key under its name and reads it back with the prefix put on again.
316    #[test]
317    fn only_temp_is_kept_out_of_storage() {
318        assert!(!StateScope::Temp.is_stored());
319        assert!(StateScope::User.is_stored());
320        assert!(StateScope::Context.is_stored());
321    }
322
323    /// The case the prefixes exist for. ADK also has `app:`, which this does
324    /// not implement — read as an ordinary key it would be stored per context
325    /// under a name promising the opposite.
326    #[test]
327    fn an_unknown_prefix_is_refused_rather_than_read_as_a_name() {
328        let err = "app:tone".parse::<StateKey>().unwrap_err();
329        assert_eq!(
330            err,
331            StateKeyError::UnknownScope {
332                prefix: "app".to_string(),
333                name: "tone".to_string(),
334            }
335        );
336        // And the message says what to write instead, because the reader is a
337        // model choosing a key, not someone reading these docs.
338        assert!(err.to_string().contains("user:tone"));
339    }
340
341    #[test]
342    fn an_empty_key_is_refused_with_or_without_a_prefix() {
343        assert_eq!("".parse::<StateKey>(), Err(StateKeyError::Empty));
344        assert_eq!("   ".parse::<StateKey>(), Err(StateKeyError::Empty));
345        assert_eq!("user:".parse::<StateKey>(), Err(StateKeyError::Empty));
346    }
347
348    /// Keys are rendered into every request, and a newline in one would break
349    /// the block it is rendered into.
350    #[test]
351    fn control_characters_are_refused() {
352        assert_eq!(
353            "to\nne".parse::<StateKey>(),
354            Err(StateKeyError::ControlCharacter)
355        );
356    }
357
358    #[test]
359    fn an_over_long_key_is_refused() {
360        let long = "k".repeat(StateKey::MAX_LEN + 1);
361        assert!(matches!(
362            long.parse::<StateKey>(),
363            Err(StateKeyError::TooLong { .. })
364        ));
365    }
366
367    /// Rendering order is fixed so the same facts produce the same prompt
368    /// prefix on every turn. `user:` keys sort ahead of bare ones because
369    /// `StateScope::User` is declared first.
370    #[test]
371    fn entries_iterate_in_a_stable_order() {
372        let mut state = ContextState::new();
373        state.insert(key("project"), "a2a-rs");
374        state.insert(key("user:tone"), "brief");
375        state.insert(key("area"), "storage");
376
377        let rendered: Vec<String> = state.iter().map(|(k, v)| format!("{k}={v}")).collect();
378        assert_eq!(
379            rendered,
380            ["user:tone=brief", "area=storage", "project=a2a-rs"]
381        );
382    }
383
384    #[test]
385    fn writing_the_same_key_twice_replaces_it() {
386        let mut state = ContextState::new();
387        state.insert(key("project"), "old");
388        state.insert(key("project"), "new");
389        assert_eq!(state.len(), 1);
390        assert_eq!(state.get(&key("project")), Some("new"));
391    }
392
393    #[test]
394    fn removing_reports_whether_anything_was_there() {
395        let mut state = ContextState::new();
396        state.insert(key("project"), "a2a-rs");
397        assert!(state.remove(&key("project")));
398        assert!(!state.remove(&key("project")));
399        assert!(state.is_empty());
400    }
401}