Skip to main content

adk_graph/
state.rs

1//! State management for graph execution
2//!
3//! Provides typed state with reducers for controlling how updates are merged.
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7use std::collections::HashMap;
8use std::sync::Arc;
9
10/// Graph state - a map of channel names to values
11pub type State = HashMap<String, Value>;
12
13/// Reducer determines how state updates are merged
14#[derive(Clone)]
15pub enum Reducer {
16    /// Replace the value entirely (default)
17    Overwrite,
18    /// Append to a list
19    Append,
20    /// Sum numeric values
21    Sum,
22    /// Custom merge function
23    Custom(Arc<dyn Fn(Value, Value) -> Value + Send + Sync>),
24}
25
26// Cannot derive Default because of the Custom variant with Arc<dyn Fn>
27#[allow(clippy::derivable_impls)]
28impl Default for Reducer {
29    fn default() -> Self {
30        Self::Overwrite
31    }
32}
33
34impl std::fmt::Debug for Reducer {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::Overwrite => write!(f, "Overwrite"),
38            Self::Append => write!(f, "Append"),
39            Self::Sum => write!(f, "Sum"),
40            Self::Custom(_) => write!(f, "Custom"),
41        }
42    }
43}
44
45/// Channel definition for a state field
46#[derive(Clone)]
47pub struct Channel {
48    /// Channel name
49    pub name: String,
50    /// Reducer for merging updates
51    pub reducer: Reducer,
52    /// Default value
53    pub default: Option<Value>,
54}
55
56impl Channel {
57    /// Create a new channel with overwrite semantics
58    pub fn new(name: &str) -> Self {
59        Self { name: name.to_string(), reducer: Reducer::Overwrite, default: None }
60    }
61
62    /// Create a list channel with append semantics
63    pub fn list(name: &str) -> Self {
64        Self { name: name.to_string(), reducer: Reducer::Append, default: Some(json!([])) }
65    }
66
67    /// Create a counter channel with sum semantics
68    pub fn counter(name: &str) -> Self {
69        Self { name: name.to_string(), reducer: Reducer::Sum, default: Some(json!(0)) }
70    }
71
72    /// Set the reducer
73    pub fn with_reducer(mut self, reducer: Reducer) -> Self {
74        self.reducer = reducer;
75        self
76    }
77
78    /// Set the default value
79    pub fn with_default(mut self, default: Value) -> Self {
80        self.default = Some(default);
81        self
82    }
83}
84
85/// State schema defines channels and their reducers
86#[derive(Clone, Default)]
87pub struct StateSchema {
88    /// Channel definitions
89    pub channels: HashMap<String, Channel>,
90}
91
92impl StateSchema {
93    /// Create a new empty schema
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Create a schema builder
99    pub fn builder() -> StateSchemaBuilder {
100        StateSchemaBuilder::default()
101    }
102
103    /// Create a simple schema with just channel names (all overwrite)
104    pub fn simple(channels: &[&str]) -> Self {
105        let mut schema = Self::new();
106        for name in channels {
107            schema.channels.insert((*name).to_string(), Channel::new(name));
108        }
109        schema
110    }
111
112    /// Get the reducer for a channel
113    pub fn get_reducer(&self, channel: &str) -> &Reducer {
114        self.channels.get(channel).map(|c| &c.reducer).unwrap_or(&Reducer::Overwrite)
115    }
116
117    /// Returns the first of `keys` that this schema does not declare.
118    ///
119    /// Returns `None` when the schema declares no channels, because then there
120    /// is nothing to check against.
121    ///
122    /// # Example
123    ///
124    /// ```
125    /// use adk_graph::state::StateSchema;
126    ///
127    /// let schema = StateSchema::simple(&["kept"]);
128    /// assert_eq!(schema.first_undeclared(["kept"]), None);
129    /// assert_eq!(schema.first_undeclared(["kept", "typo"]), Some("typo"));
130    /// ```
131    pub fn first_undeclared<'a>(&self, keys: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
132        if self.channels.is_empty() {
133            return None;
134        }
135        keys.into_iter().find(|key| !self.channels.contains_key(*key))
136    }
137
138    /// Get the default value for a channel
139    pub fn get_default(&self, channel: &str) -> Option<&Value> {
140        self.channels.get(channel).and_then(|c| c.default.as_ref())
141    }
142
143    /// Apply an update to state using the appropriate reducer
144    pub fn apply_update(&self, state: &mut State, key: &str, value: Value) {
145        let reducer = self.get_reducer(key);
146        let current = state.get(key).cloned().unwrap_or(Value::Null);
147
148        let new_value = match reducer {
149            Reducer::Overwrite => value,
150            Reducer::Append => {
151                let mut arr = match current {
152                    Value::Array(a) => a,
153                    Value::Null => vec![],
154                    _ => vec![current],
155                };
156                match value {
157                    Value::Array(items) => arr.extend(items),
158                    _ => arr.push(value),
159                }
160                Value::Array(arr)
161            }
162            Reducer::Sum => {
163                let current_num = current.as_f64().unwrap_or(0.0);
164                let add_num = value.as_f64().unwrap_or(0.0);
165                json!(current_num + add_num)
166            }
167            Reducer::Custom(f) => f(current, value),
168        };
169
170        state.insert(key.to_string(), new_value);
171    }
172
173    /// Initialize state with default values
174    pub fn initialize_state(&self) -> State {
175        let mut state = State::new();
176        for (name, channel) in &self.channels {
177            if let Some(default) = &channel.default {
178                state.insert(name.clone(), default.clone());
179            }
180        }
181        state
182    }
183}
184
185/// Builder for StateSchema
186#[derive(Default)]
187pub struct StateSchemaBuilder {
188    channels: HashMap<String, Channel>,
189}
190
191impl StateSchemaBuilder {
192    /// Add a channel with overwrite semantics
193    pub fn channel(mut self, name: &str) -> Self {
194        self.channels.insert(name.to_string(), Channel::new(name));
195        self
196    }
197
198    /// Add a channel with append semantics (for lists)
199    pub fn list_channel(mut self, name: &str) -> Self {
200        self.channels.insert(name.to_string(), Channel::list(name));
201        self
202    }
203
204    /// Add a counter channel with sum semantics
205    pub fn counter_channel(mut self, name: &str) -> Self {
206        self.channels.insert(name.to_string(), Channel::counter(name));
207        self
208    }
209
210    /// Add a channel with custom reducer
211    pub fn channel_with_reducer(mut self, name: &str, reducer: Reducer) -> Self {
212        self.channels.insert(name.to_string(), Channel::new(name).with_reducer(reducer));
213        self
214    }
215
216    /// Add a channel with default value
217    pub fn channel_with_default(mut self, name: &str, default: Value) -> Self {
218        self.channels.insert(name.to_string(), Channel::new(name).with_default(default));
219        self
220    }
221
222    /// Build the schema
223    pub fn build(self) -> StateSchema {
224        StateSchema { channels: self.channels }
225    }
226}
227
228/// Checkpoint data structure for persistence
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Checkpoint {
231    /// Thread identifier
232    pub thread_id: String,
233    /// Unique checkpoint ID
234    pub checkpoint_id: String,
235    /// State at this checkpoint
236    pub state: State,
237    /// Step number
238    pub step: usize,
239    /// Nodes pending execution
240    pub pending_nodes: Vec<String>,
241    /// Additional metadata
242    pub metadata: HashMap<String, Value>,
243    /// Creation timestamp
244    pub created_at: chrono::DateTime<chrono::Utc>,
245    /// The node whose static interrupt produced this checkpoint.
246    ///
247    /// A static interrupt is raised before the node runs, so the checkpoint holds
248    /// a frontier that still contains it. Without this marker the resumed run
249    /// reaches the same conclusion and raises the same interrupt, and the node
250    /// never executes. The executor clears the marker once that node has run, so
251    /// a cycle returning to the same gate asks again.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub cleared_interrupt: Option<String>,
254    /// How many times each node has been attempted, for retry policies.
255    ///
256    /// Held here so a retry budget survives a resume. adk-python does not persist
257    /// its attempt count, so a resumed node there starts its budget again.
258    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
259    pub attempts: HashMap<String, u32>,
260    /// Outputs of children invoked imperatively from a node body, keyed by child
261    /// path.
262    ///
263    /// A resumed parent re-runs from the top, so without this every child would
264    /// run again. Only successful outputs are recorded: a failed or interrupted
265    /// child must re-run.
266    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
267    pub child_ledger: HashMap<String, Value>,
268}
269
270impl Checkpoint {
271    /// Create a new checkpoint
272    pub fn new(thread_id: &str, state: State, step: usize, pending_nodes: Vec<String>) -> Self {
273        Self {
274            thread_id: thread_id.to_string(),
275            checkpoint_id: uuid::Uuid::new_v4().to_string(),
276            state,
277            step,
278            pending_nodes,
279            metadata: HashMap::new(),
280            created_at: chrono::Utc::now(),
281            cleared_interrupt: None,
282            attempts: HashMap::new(),
283            child_ledger: HashMap::new(),
284        }
285    }
286
287    /// Record the node whose static interrupt produced this checkpoint.
288    pub fn with_cleared_interrupt(mut self, node: impl Into<String>) -> Self {
289        self.cleared_interrupt = Some(node.into());
290        self
291    }
292
293    /// Add metadata to the checkpoint
294    pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
295        self.metadata.insert(key.to_string(), value);
296        self
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_overwrite_reducer() {
306        let schema = StateSchema::simple(&["value"]);
307        let mut state = State::new();
308
309        schema.apply_update(&mut state, "value", json!(1));
310        assert_eq!(state.get("value"), Some(&json!(1)));
311
312        schema.apply_update(&mut state, "value", json!(2));
313        assert_eq!(state.get("value"), Some(&json!(2)));
314    }
315
316    #[test]
317    fn test_append_reducer() {
318        let schema = StateSchema::builder().list_channel("messages").build();
319        let mut state = schema.initialize_state();
320
321        schema.apply_update(&mut state, "messages", json!({"role": "user", "content": "hi"}));
322        assert_eq!(state.get("messages"), Some(&json!([{"role": "user", "content": "hi"}])));
323
324        schema.apply_update(
325            &mut state,
326            "messages",
327            json!([{"role": "assistant", "content": "hello"}]),
328        );
329        assert_eq!(
330            state.get("messages"),
331            Some(&json!([
332                {"role": "user", "content": "hi"},
333                {"role": "assistant", "content": "hello"}
334            ]))
335        );
336    }
337
338    #[test]
339    fn test_sum_reducer() {
340        let schema = StateSchema::builder().counter_channel("count").build();
341        let mut state = schema.initialize_state();
342
343        assert_eq!(state.get("count"), Some(&json!(0)));
344
345        schema.apply_update(&mut state, "count", json!(5));
346        assert_eq!(state.get("count"), Some(&json!(5.0)));
347
348        schema.apply_update(&mut state, "count", json!(3));
349        assert_eq!(state.get("count"), Some(&json!(8.0)));
350    }
351
352    #[test]
353    fn test_custom_reducer() {
354        let schema = StateSchema::builder()
355            .channel_with_reducer(
356                "max",
357                Reducer::Custom(Arc::new(|a, b| {
358                    let a_num = a.as_f64().unwrap_or(f64::MIN);
359                    let b_num = b.as_f64().unwrap_or(f64::MIN);
360                    json!(a_num.max(b_num))
361                })),
362            )
363            .build();
364        let mut state = State::new();
365
366        schema.apply_update(&mut state, "max", json!(5));
367        schema.apply_update(&mut state, "max", json!(3));
368        schema.apply_update(&mut state, "max", json!(8));
369        assert_eq!(state.get("max"), Some(&json!(8.0)));
370    }
371}