use super::display::{print_tool_call, print_tool_output};
use std::collections::BTreeMap;
use std::sync::Mutex;
pub(crate) const STATE_PER_VALUE_CAP: usize = 2_000;
pub(crate) const STATE_TOTAL_CAP: usize = 8_000;
pub trait ScratchpadStore: Send + Sync {
fn set(&self, key: &str, value: String);
fn get(&self, key: &str) -> Option<String>;
fn entries(&self) -> BTreeMap<String, String>;
fn clear(&self);
fn keys_count(&self) -> u64;
fn state_chars(&self) -> u64;
}
#[derive(Default)]
pub struct SessionScratchpadStore {
map: Mutex<BTreeMap<String, String>>,
}
impl ScratchpadStore for SessionScratchpadStore {
fn set(&self, key: &str, value: String) {
self.map.lock().unwrap().insert(key.to_string(), value);
}
fn get(&self, key: &str) -> Option<String> {
self.map.lock().unwrap().get(key).cloned()
}
fn entries(&self) -> BTreeMap<String, String> {
self.map.lock().unwrap().clone()
}
fn clear(&self) {
self.map.lock().unwrap().clear();
}
fn keys_count(&self) -> u64 {
self.map.lock().unwrap().len() as u64
}
fn state_chars(&self) -> u64 {
self.map
.lock()
.unwrap()
.values()
.map(|v| v.chars().count() as u64)
.sum()
}
}
pub(crate) fn build_state_block(
store: &dyn ScratchpadStore,
per_value_cap: usize,
total_cap: usize,
) -> Option<String> {
let entries = store.entries();
if entries.is_empty() {
return None;
}
let mut body = String::from("<state>\n");
for (k, v) in &entries {
let shown = if v.chars().count() > per_value_cap {
let head: String = v.chars().take(per_value_cap).collect();
format!("{head}[…]")
} else {
v.clone()
};
let line = format!("{k}: {shown}\n");
if body.chars().count() + line.chars().count() + "</state>".len() > total_cap {
body.push_str("[… state truncated to fit the budget …]\n");
break;
}
body.push_str(&line);
}
body.push_str("</state>");
Some(body)
}
pub fn scratchpad_state_block(store: &dyn ScratchpadStore) -> Option<String> {
build_state_block(store, STATE_PER_VALUE_CAP, STATE_TOTAL_CAP)
}
pub fn state_set_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "state_set",
"description": "Record or update one piece of working state (a subtask's \
status, an open file path, a variable) that should persist \
across turns. The whole <state> block is shown to you at the \
head of every turn, so set what you need to remember and read \
it back there — don't restate it in prose.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "A short stable name, e.g. 'current_subtask' or 'open_file'." },
"value": { "type": "string", "description": "The value to store; overwrites any existing value for this key." }
},
"required": ["key", "value"]
}
}
})
}
pub fn state_get_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "state_get",
"description": "Read back one key's FULL value (the <state> block at the head \
of the turn may truncate long values). Use this to confirm a \
single value without re-reading the whole block.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "The key to read." }
},
"required": ["key"]
}
}
})
}
pub fn state_clear_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "state_clear",
"description": "Drop ALL scratchpad state — use when a task is done and its \
tracking variables are stale.",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
})
}
pub(crate) fn execute_state_set(
args: &serde_json::Value,
store: &dyn ScratchpadStore,
color: bool,
tool_output_lines: usize,
) -> String {
let key = args["key"].as_str().unwrap_or("").trim();
let value = args["value"].as_str().unwrap_or("");
print_tool_call("state_set", key, color);
if key.is_empty() {
return "error: state_set requires a non-empty `key` (and a `value`)".to_string();
}
let existed = store.get(key).is_some();
store.set(key, value.to_string());
let out = if existed {
format!("updated: {key}")
} else {
format!("stored: {key}")
};
print_tool_output(&out, tool_output_lines, color);
out
}
pub(crate) fn execute_state_get(
args: &serde_json::Value,
store: &dyn ScratchpadStore,
color: bool,
tool_output_lines: usize,
) -> String {
let key = args["key"].as_str().unwrap_or("").trim();
print_tool_call("state_get", key, color);
if key.is_empty() {
return "error: state_get requires a non-empty `key`".to_string();
}
let out = match store.get(key) {
Some(v) => v,
None => format!("no such key: {key}"),
};
print_tool_output(&out, tool_output_lines, color);
out
}
pub(crate) fn execute_state_clear(
store: &dyn ScratchpadStore,
color: bool,
tool_output_lines: usize,
) -> String {
print_tool_call("state_clear", "", color);
let n = store.keys_count();
store.clear();
let out = format!("cleared {n} entries");
print_tool_output(&out, tool_output_lines, color);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_set_get_overwrite_clear_and_stats() {
let s = SessionScratchpadStore::default();
assert_eq!(s.keys_count(), 0);
s.set("b", "two".to_string());
s.set("a", "one".to_string());
assert_eq!(s.get("a").as_deref(), Some("one"));
assert_eq!(s.get("missing"), None);
assert_eq!(
s.entries().keys().cloned().collect::<Vec<_>>(),
vec!["a".to_string(), "b".to_string()]
);
s.set("a", "ONE!".to_string());
assert_eq!(s.get("a").as_deref(), Some("ONE!"));
assert_eq!(s.keys_count(), 2);
assert_eq!(s.state_chars(), 4 + 3); s.clear();
assert_eq!(s.keys_count(), 0);
assert_eq!(s.state_chars(), 0);
}
#[test]
fn build_state_block_none_when_empty_and_sorted_when_full() {
let s = SessionScratchpadStore::default();
assert_eq!(build_state_block(&s, 100, 1000), None, "empty → no block");
s.set("zeta", "last".to_string());
s.set("alpha", "first".to_string());
let block = build_state_block(&s, 100, 1000).unwrap();
assert!(block.starts_with("<state>\n") && block.ends_with("</state>"));
let a = block.find("alpha").unwrap();
let z = block.find("zeta").unwrap();
assert!(a < z, "{block}");
assert!(block.contains("alpha: first") && block.contains("zeta: last"));
}
#[test]
fn build_state_block_truncates_per_value_and_total() {
let s = SessionScratchpadStore::default();
s.set("big", "x".repeat(50));
let block = build_state_block(&s, 10, 1000).unwrap();
assert!(block.contains("[…]"), "long value truncated: {block}");
assert_eq!(s.get("big").unwrap().chars().count(), 50);
let s2 = SessionScratchpadStore::default();
for i in 0..50 {
s2.set(&format!("k{i:02}"), "v".repeat(40));
}
let block2 = build_state_block(&s2, 2_000, 200).unwrap();
assert!(
block2.chars().count() <= 200 + 60,
"total cap bounds the block"
);
assert!(block2.contains("state truncated"), "{block2:.120}");
}
#[test]
fn executors_round_trip_and_coach() {
let s = SessionScratchpadStore::default();
assert_eq!(
execute_state_set(
&serde_json::json!({"key": "k", "value": "v"}),
&s,
false,
20
),
"stored: k"
);
assert_eq!(
execute_state_set(
&serde_json::json!({"key": "k", "value": "v2"}),
&s,
false,
20
),
"updated: k"
);
assert!(
execute_state_set(&serde_json::json!({"value": "v"}), &s, false, 20)
.starts_with("error:")
);
assert_eq!(
execute_state_get(&serde_json::json!({"key": "k"}), &s, false, 20),
"v2"
);
assert_eq!(
execute_state_get(&serde_json::json!({"key": "nope"}), &s, false, 20),
"no such key: nope"
);
assert!(execute_state_get(&serde_json::json!({}), &s, false, 20).starts_with("error:"));
assert_eq!(execute_state_clear(&s, false, 20), "cleared 1 entries");
assert_eq!(s.keys_count(), 0);
}
}