1use super::display::{print_tool_call, print_tool_output};
15use std::collections::BTreeMap;
16use std::sync::Mutex;
17
18pub(crate) const STATE_PER_VALUE_CAP: usize = 2_000;
21pub(crate) const STATE_TOTAL_CAP: usize = 8_000;
23
24pub trait ScratchpadStore: Send + Sync {
28 fn set(&self, key: &str, value: String);
30 fn get(&self, key: &str) -> Option<String>;
32 fn entries(&self) -> BTreeMap<String, String>;
34 fn clear(&self);
36 fn keys_count(&self) -> u64;
38 fn state_chars(&self) -> u64;
40}
41
42#[derive(Default)]
46pub struct SessionScratchpadStore {
47 map: Mutex<BTreeMap<String, String>>,
48}
49
50impl ScratchpadStore for SessionScratchpadStore {
51 fn set(&self, key: &str, value: String) {
52 self.map.lock().unwrap().insert(key.to_string(), value);
53 }
54 fn get(&self, key: &str) -> Option<String> {
55 self.map.lock().unwrap().get(key).cloned()
56 }
57 fn entries(&self) -> BTreeMap<String, String> {
58 self.map.lock().unwrap().clone()
59 }
60 fn clear(&self) {
61 self.map.lock().unwrap().clear();
62 }
63 fn keys_count(&self) -> u64 {
64 self.map.lock().unwrap().len() as u64
65 }
66 fn state_chars(&self) -> u64 {
67 self.map
68 .lock()
69 .unwrap()
70 .values()
71 .map(|v| v.chars().count() as u64)
72 .sum()
73 }
74}
75
76pub(crate) fn build_state_block(
82 store: &dyn ScratchpadStore,
83 per_value_cap: usize,
84 total_cap: usize,
85) -> Option<String> {
86 let entries = store.entries();
87 if entries.is_empty() {
88 return None;
89 }
90 let mut body = String::from("<state>\n");
91 for (k, v) in &entries {
92 let shown = if v.chars().count() > per_value_cap {
93 let head: String = v.chars().take(per_value_cap).collect();
94 format!("{head}[…]")
95 } else {
96 v.clone()
97 };
98 let line = format!("{k}: {shown}\n");
99 if body.chars().count() + line.chars().count() + "</state>".len() > total_cap {
101 body.push_str("[… state truncated to fit the budget …]\n");
102 break;
103 }
104 body.push_str(&line);
105 }
106 body.push_str("</state>");
107 Some(body)
108}
109
110pub fn scratchpad_state_block(store: &dyn ScratchpadStore) -> Option<String> {
113 build_state_block(store, STATE_PER_VALUE_CAP, STATE_TOTAL_CAP)
114}
115
116pub fn state_set_tool_definition() -> serde_json::Value {
122 serde_json::json!({
123 "type": "function",
124 "function": {
125 "name": "state_set",
126 "description": "Record or update one piece of working state (a subtask's \
127 status, an open file path, a variable) that should persist \
128 across turns. The whole <state> block is shown to you at the \
129 head of every turn, so set what you need to remember and read \
130 it back there — don't restate it in prose.",
131 "parameters": {
132 "type": "object",
133 "properties": {
134 "key": { "type": "string", "description": "A short stable name, e.g. 'current_subtask' or 'open_file'." },
135 "value": { "type": "string", "description": "The value to store; overwrites any existing value for this key." }
136 },
137 "required": ["key", "value"]
138 }
139 }
140 })
141}
142
143pub fn state_get_tool_definition() -> serde_json::Value {
145 serde_json::json!({
146 "type": "function",
147 "function": {
148 "name": "state_get",
149 "description": "Read back one key's FULL value (the <state> block at the head \
150 of the turn may truncate long values). Use this to confirm a \
151 single value without re-reading the whole block.",
152 "parameters": {
153 "type": "object",
154 "properties": {
155 "key": { "type": "string", "description": "The key to read." }
156 },
157 "required": ["key"]
158 }
159 }
160 })
161}
162
163pub fn state_clear_tool_definition() -> serde_json::Value {
165 serde_json::json!({
166 "type": "function",
167 "function": {
168 "name": "state_clear",
169 "description": "Drop ALL scratchpad state — use when a task is done and its \
170 tracking variables are stale.",
171 "parameters": { "type": "object", "properties": {}, "required": [] }
172 }
173 })
174}
175
176pub(crate) fn execute_state_set(
182 args: &serde_json::Value,
183 store: &dyn ScratchpadStore,
184 color: bool,
185 tool_output_lines: usize,
186) -> String {
187 let key = args["key"].as_str().unwrap_or("").trim();
188 let value = args["value"].as_str().unwrap_or("");
189 print_tool_call("state_set", key, color);
190 if key.is_empty() {
191 return "error: state_set requires a non-empty `key` (and a `value`)".to_string();
192 }
193 let existed = store.get(key).is_some();
194 store.set(key, value.to_string());
195 let out = if existed {
196 format!("updated: {key}")
197 } else {
198 format!("stored: {key}")
199 };
200 print_tool_output(&out, tool_output_lines, color);
201 out
202}
203
204pub(crate) fn execute_state_get(
206 args: &serde_json::Value,
207 store: &dyn ScratchpadStore,
208 color: bool,
209 tool_output_lines: usize,
210) -> String {
211 let key = args["key"].as_str().unwrap_or("").trim();
212 print_tool_call("state_get", key, color);
213 if key.is_empty() {
214 return "error: state_get requires a non-empty `key`".to_string();
215 }
216 let out = match store.get(key) {
217 Some(v) => v,
218 None => format!("no such key: {key}"),
219 };
220 print_tool_output(&out, tool_output_lines, color);
221 out
222}
223
224pub(crate) fn execute_state_clear(
226 store: &dyn ScratchpadStore,
227 color: bool,
228 tool_output_lines: usize,
229) -> String {
230 print_tool_call("state_clear", "", color);
231 let n = store.keys_count();
232 store.clear();
233 let out = format!("cleared {n} entries");
234 print_tool_output(&out, tool_output_lines, color);
235 out
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn store_set_get_overwrite_clear_and_stats() {
244 let s = SessionScratchpadStore::default();
245 assert_eq!(s.keys_count(), 0);
246 s.set("b", "two".to_string());
247 s.set("a", "one".to_string());
248 assert_eq!(s.get("a").as_deref(), Some("one"));
249 assert_eq!(s.get("missing"), None);
250 assert_eq!(
252 s.entries().keys().cloned().collect::<Vec<_>>(),
253 vec!["a".to_string(), "b".to_string()]
254 );
255 s.set("a", "ONE!".to_string());
257 assert_eq!(s.get("a").as_deref(), Some("ONE!"));
258 assert_eq!(s.keys_count(), 2);
259 assert_eq!(s.state_chars(), 4 + 3); s.clear();
261 assert_eq!(s.keys_count(), 0);
262 assert_eq!(s.state_chars(), 0);
263 }
264
265 #[test]
266 fn build_state_block_none_when_empty_and_sorted_when_full() {
267 let s = SessionScratchpadStore::default();
268 assert_eq!(build_state_block(&s, 100, 1000), None, "empty → no block");
269 s.set("zeta", "last".to_string());
270 s.set("alpha", "first".to_string());
271 let block = build_state_block(&s, 100, 1000).unwrap();
272 assert!(block.starts_with("<state>\n") && block.ends_with("</state>"));
273 let a = block.find("alpha").unwrap();
275 let z = block.find("zeta").unwrap();
276 assert!(a < z, "{block}");
277 assert!(block.contains("alpha: first") && block.contains("zeta: last"));
278 }
279
280 #[test]
281 fn build_state_block_truncates_per_value_and_total() {
282 let s = SessionScratchpadStore::default();
283 s.set("big", "x".repeat(50));
284 let block = build_state_block(&s, 10, 1000).unwrap();
285 assert!(block.contains("[…]"), "long value truncated: {block}");
286 assert_eq!(s.get("big").unwrap().chars().count(), 50);
288 let s2 = SessionScratchpadStore::default();
290 for i in 0..50 {
291 s2.set(&format!("k{i:02}"), "v".repeat(40));
292 }
293 let block2 = build_state_block(&s2, 2_000, 200).unwrap();
294 assert!(
295 block2.chars().count() <= 200 + 60,
296 "total cap bounds the block"
297 );
298 assert!(block2.contains("state truncated"), "{block2:.120}");
299 }
300
301 #[test]
302 fn executors_round_trip_and_coach() {
303 let s = SessionScratchpadStore::default();
304 assert_eq!(
306 execute_state_set(
307 &serde_json::json!({"key": "k", "value": "v"}),
308 &s,
309 false,
310 20
311 ),
312 "stored: k"
313 );
314 assert_eq!(
315 execute_state_set(
316 &serde_json::json!({"key": "k", "value": "v2"}),
317 &s,
318 false,
319 20
320 ),
321 "updated: k"
322 );
323 assert!(
325 execute_state_set(&serde_json::json!({"value": "v"}), &s, false, 20)
326 .starts_with("error:")
327 );
328 assert_eq!(
330 execute_state_get(&serde_json::json!({"key": "k"}), &s, false, 20),
331 "v2"
332 );
333 assert_eq!(
334 execute_state_get(&serde_json::json!({"key": "nope"}), &s, false, 20),
335 "no such key: nope"
336 );
337 assert!(execute_state_get(&serde_json::json!({}), &s, false, 20).starts_with("error:"));
338 assert_eq!(execute_state_clear(&s, false, 20), "cleared 1 entries");
340 assert_eq!(s.keys_count(), 0);
341 }
342}