agents_core/
state.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4/// Snapshot of agent state shared between runtime, planners, and tools.
5#[derive(Debug, Default, Clone, Serialize, Deserialize)]
6pub struct AgentStateSnapshot {
7    pub todos: Vec<TodoItem>,
8    pub files: BTreeMap<String, String>,
9    pub scratchpad: BTreeMap<String, serde_json::Value>,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TodoItem {
14    pub content: String,
15    pub status: TodoStatus,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum TodoStatus {
21    Pending,
22    InProgress,
23    Completed,
24}
25
26impl TodoItem {
27    pub fn pending(content: impl Into<String>) -> Self {
28        Self {
29            content: content.into(),
30            status: TodoStatus::Pending,
31        }
32    }
33}
34
35impl AgentStateSnapshot {
36    /// Merge another state snapshot into this one using reducer logic.
37    pub fn merge(&mut self, other: AgentStateSnapshot) {
38        // Files reducer: merge dictionaries (equivalent to {**l, **r})
39        self.files.extend(other.files);
40
41        // Todos reducer: replace with other if not empty, otherwise keep current
42        if !other.todos.is_empty() {
43            self.todos = other.todos;
44        }
45
46        // Scratchpad reducer: merge dictionaries
47        self.scratchpad.extend(other.scratchpad);
48    }
49
50    /// File reducer function matching Python's file_reducer behavior.
51    /// Merges two optional file dictionaries, handling None values appropriately.
52    pub fn reduce_files(
53        left: Option<BTreeMap<String, String>>,
54        right: Option<BTreeMap<String, String>>,
55    ) -> Option<BTreeMap<String, String>> {
56        match (left, right) {
57            (None, None) => None,
58            (Some(l), None) => Some(l),
59            (None, Some(r)) => Some(r),
60            (Some(mut l), Some(r)) => {
61                l.extend(r); // Equivalent to Python's {**l, **r}
62                Some(l)
63            }
64        }
65    }
66
67    /// Create a new state with merged files, handling None values.
68    pub fn with_merged_files(&self, new_files: Option<BTreeMap<String, String>>) -> Self {
69        let mut result = self.clone();
70        if let Some(files) = new_files {
71            result.files.extend(files);
72        }
73        result
74    }
75
76    pub fn with_updated_todos(&self, new_todos: Vec<TodoItem>) -> Self {
77        if new_todos.is_empty() {
78            self.clone()
79        } else {
80            let mut result = self.clone();
81            result.todos = new_todos;
82            result
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_file_reducer_both_none() {
93        let result = AgentStateSnapshot::reduce_files(None, None);
94        assert!(result.is_none());
95    }
96
97    #[test]
98    fn test_file_reducer_left_some_right_none() {
99        let mut left = BTreeMap::new();
100        left.insert("file1.txt".to_string(), "content1".to_string());
101
102        let result = AgentStateSnapshot::reduce_files(Some(left.clone()), None);
103        assert_eq!(result, Some(left));
104    }
105
106    #[test]
107    fn test_file_reducer_left_none_right_some() {
108        let mut right = BTreeMap::new();
109        right.insert("file2.txt".to_string(), "content2".to_string());
110
111        let result = AgentStateSnapshot::reduce_files(None, Some(right.clone()));
112        assert_eq!(result, Some(right));
113    }
114
115    #[test]
116    fn test_file_reducer_both_some_merges() {
117        let mut left = BTreeMap::new();
118        left.insert("file1.txt".to_string(), "content1".to_string());
119        left.insert("shared.txt".to_string(), "old_content".to_string());
120
121        let mut right = BTreeMap::new();
122        right.insert("file2.txt".to_string(), "content2".to_string());
123        right.insert("shared.txt".to_string(), "new_content".to_string());
124
125        let result = AgentStateSnapshot::reduce_files(Some(left), Some(right)).unwrap();
126
127        // Should have all files, with right overwriting left for conflicts
128        assert_eq!(result.get("file1.txt").unwrap(), "content1");
129        assert_eq!(result.get("file2.txt").unwrap(), "content2");
130        assert_eq!(result.get("shared.txt").unwrap(), "new_content"); // Right wins
131        assert_eq!(result.len(), 3);
132    }
133
134    #[test]
135    fn test_merge_combines_states() {
136        let mut state1 = AgentStateSnapshot::default();
137        state1
138            .files
139            .insert("file1.txt".to_string(), "content1".to_string());
140        state1.todos.push(TodoItem::pending("task1"));
141        state1
142            .scratchpad
143            .insert("key1".to_string(), serde_json::json!("value1"));
144
145        let mut state2 = AgentStateSnapshot::default();
146        state2
147            .files
148            .insert("file2.txt".to_string(), "content2".to_string());
149        state2.todos.push(TodoItem::pending("task2"));
150        state2
151            .scratchpad
152            .insert("key2".to_string(), serde_json::json!("value2"));
153
154        let mut merged = state1.clone();
155        merged.merge(state2);
156
157        // Files should be merged
158        assert_eq!(merged.files.len(), 2);
159        assert_eq!(merged.files.get("file1.txt").unwrap(), "content1");
160        assert_eq!(merged.files.get("file2.txt").unwrap(), "content2");
161
162        // Todos should be replaced (not empty)
163        assert_eq!(merged.todos.len(), 1);
164        assert_eq!(merged.todos[0].content, "task2");
165
166        // Scratchpad should be merged
167        assert_eq!(merged.scratchpad.len(), 2);
168        assert_eq!(merged.scratchpad.get("key1").unwrap(), "value1");
169        assert_eq!(merged.scratchpad.get("key2").unwrap(), "value2");
170    }
171
172    #[test]
173    fn test_merge_empty_todos_preserves_existing() {
174        let mut state1 = AgentStateSnapshot::default();
175        state1.todos.push(TodoItem::pending("task1"));
176
177        let state2 = AgentStateSnapshot::default(); // Empty todos
178
179        let mut merged = state1.clone();
180        merged.merge(state2);
181
182        // Should preserve original todos since new ones are empty
183        assert_eq!(merged.todos.len(), 1);
184        assert_eq!(merged.todos[0].content, "task1");
185    }
186
187    #[test]
188    fn test_with_merged_files() {
189        let mut state = AgentStateSnapshot::default();
190        state
191            .files
192            .insert("existing.txt".to_string(), "existing".to_string());
193
194        let mut new_files = BTreeMap::new();
195        new_files.insert("new.txt".to_string(), "new_content".to_string());
196        new_files.insert("existing.txt".to_string(), "updated".to_string()); // Should overwrite
197
198        let result = state.with_merged_files(Some(new_files));
199
200        assert_eq!(result.files.len(), 2);
201        assert_eq!(result.files.get("existing.txt").unwrap(), "updated");
202        assert_eq!(result.files.get("new.txt").unwrap(), "new_content");
203    }
204
205    #[test]
206    fn test_with_updated_todos() {
207        let mut state = AgentStateSnapshot::default();
208        state.todos.push(TodoItem::pending("old_task"));
209
210        let new_todos = vec![
211            TodoItem::pending("new_task1"),
212            TodoItem::pending("new_task2"),
213        ];
214
215        let result = state.with_updated_todos(new_todos);
216
217        assert_eq!(result.todos.len(), 2);
218        assert_eq!(result.todos[0].content, "new_task1");
219        assert_eq!(result.todos[1].content, "new_task2");
220    }
221
222    #[test]
223    fn test_with_updated_todos_empty_preserves_existing() {
224        let mut state = AgentStateSnapshot::default();
225        state.todos.push(TodoItem::pending("existing_task"));
226
227        let result = state.with_updated_todos(vec![]);
228
229        // Should preserve existing todos when new list is empty
230        assert_eq!(result.todos.len(), 1);
231        assert_eq!(result.todos[0].content, "existing_task");
232    }
233}