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}