Skip to main content

newt_core/agentic/
scratchpad.rs

1//! Episodic scratchpad — the `scratchpad` context feature (Step 26.4, #583).
2//!
3//! A structured `<state>` object (subtask status, open file paths, working
4//! variables) kept SEPARATE from the conversation log: it is injected at the
5//! HEAD of each turn and mutated by the model via `state_set` / `state_get` /
6//! `state_clear` tool calls, rather than inferred from a chatty history. The
7//! full snapshot is auto-injected every turn, so there is no `state_list` tool
8//! (it would just duplicate context).
9//!
10//! Session-scoped, pure in-memory (no filesystem), discarded at `/new` / session
11//! end. Mirrors `spill.rs`: a `&self` interior-mutability trait + an in-memory
12//! impl, deterministic (a `BTreeMap` — sorted, no clock/uuid) for stable tests.
13
14use super::display::{print_tool_call, print_tool_output};
15use std::collections::BTreeMap;
16use std::sync::Mutex;
17
18/// Per-value char cap when rendering the `<state>` block (longer values render
19/// truncated with `[…]`; the full value stays retrievable via `state_get`).
20pub(crate) const STATE_PER_VALUE_CAP: usize = 2_000;
21/// Whole-block char cap so a runaway scratchpad can never blow the budget.
22pub(crate) const STATE_TOTAL_CAP: usize = 8_000;
23
24/// A session store for the scratchpad `<state>` (Step 26.4). `&self` methods
25/// (interior mutability) so a single shared `&dyn ScratchpadStore` serves both
26/// the per-turn injection and the tool-mutation path.
27pub trait ScratchpadStore: Send + Sync {
28    /// Set (or overwrite) one key.
29    fn set(&self, key: &str, value: String);
30    /// Read one key's full (un-truncated) value.
31    fn get(&self, key: &str) -> Option<String>;
32    /// A sorted snapshot of all entries (for the `<state>` block).
33    fn entries(&self) -> BTreeMap<String, String>;
34    /// Drop all entries (`/new`, or the model's `state_clear`).
35    fn clear(&self);
36    /// Number of keys held (for `/context stats`).
37    fn keys_count(&self) -> u64;
38    /// Total chars across all values (for `/context stats`).
39    fn state_chars(&self) -> u64;
40}
41
42/// In-memory, session-scoped [`ScratchpadStore`] — pure (no fs), discarded at
43/// session end / `/new`. A `BTreeMap` keeps the `<state>` block in deterministic
44/// (sorted) order for stable tests.
45#[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
76/// Render the `<state>…</state>` block injected at the head of a turn (Step
77/// 26.4). `None` when the store is empty — the OFF/empty bit-for-bit guarantee
78/// (an empty scratchpad changes nothing about the turn). Per-value and
79/// whole-block caps are applied HERE (at injection), not at store time, so
80/// `state_get` can still return a full value the block truncated.
81pub(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        // Leave room for the closing tag; stop cleanly if we'd overflow.
100        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
110/// Render the `<state>` block with the default budget caps (Step 26.4) — the
111/// TUI-facing entry called per turn. `None` when the scratchpad is empty.
112pub fn scratchpad_state_block(store: &dyn ScratchpadStore) -> Option<String> {
113    build_state_block(store, STATE_PER_VALUE_CAP, STATE_TOTAL_CAP)
114}
115
116// ---------------------------------------------------------------------------
117// Tool schemas (advertised only when the feature is on + a store is present)
118// ---------------------------------------------------------------------------
119
120/// `state_set` tool definition.
121pub 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
143/// `state_get` tool definition.
144pub 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
163/// `state_clear` tool definition.
164pub 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
176// ---------------------------------------------------------------------------
177// Executors (every branch returns a tool-result String, never a loop abort)
178// ---------------------------------------------------------------------------
179
180/// Execute a `state_set` call (Step 26.4).
181pub(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
204/// Execute a `state_get` call (Step 26.4).
205pub(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
224/// Execute a `state_clear` call (Step 26.4).
225pub(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        // entries are sorted (BTreeMap) → deterministic block order
251        assert_eq!(
252            s.entries().keys().cloned().collect::<Vec<_>>(),
253            vec!["a".to_string(), "b".to_string()]
254        );
255        // overwrite replaces + stats track value chars
256        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); // "ONE!"(4) + "two"(3)
260        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        // sorted: alpha before zeta
274        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        // the FULL value is still retrievable (cap is injection-only)
287        assert_eq!(s.get("big").unwrap().chars().count(), 50);
288        // total-cap: many keys stop the block with a marker
289        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        // set (new → stored, repeat → updated)
305        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        // empty key → coaching, store untouched
324        assert!(
325            execute_state_set(&serde_json::json!({"value": "v"}), &s, false, 20)
326                .starts_with("error:")
327        );
328        // get found / not-found / empty
329        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        // clear reports the count and empties
339        assert_eq!(execute_state_clear(&s, false, 20), "cleared 1 entries");
340        assert_eq!(s.keys_count(), 0);
341    }
342}