Skip to main content

agentstatedeveloper_core/
ser_helpers.rs

1//! Serde skip-predicates for agent-mode token economy.
2//!
3//! Principle: minimize tokens the agent must process, subject to the
4//! output remaining accurate. "Accurate" means: the data is correct.
5//! Not "complete" — the agent doesn't need everything in one call.
6//! Not "self-describing" — the agent has docs/schema. So any field
7//! whose value carries no signal the agent can't derive from other
8//! emitted fields should be omitted.
9//!
10//! Practical rule: emit `null` / `[]` / `{}` / `0` ONLY when zero
11//! itself is the signal. Otherwise omit via
12//! `#[serde(skip_serializing_if = "...")]`.
13
14use std::collections::BTreeMap;
15
16use serde_json::Value;
17
18/// Token economy: drop top-level fields whose value is `null`, `[]`,
19/// or `{}`. The agent doesn't need the absence-of-X echo when X has
20/// no signal — they can infer "no entries" / "no warning" / "fresh"
21/// from absence.
22///
23/// Top-level only: nested empty arrays/objects can be load-bearing
24/// (e.g. an empty `dropped` list inside a populated bucket says
25/// something different from an absent `dropped` list).
26///
27/// 1.0.79: applied after `trim_for_agent` in CLI/MCP agent-mode
28/// hot paths. Together with the skip-zero serde predicates from
29/// 1.0.78 this catches the remaining empty-field bloat: keys
30/// whose value the json! macro emitted as null/[]/{} unconditionally.
31pub fn drop_empty_top_level(v: Value) -> Value {
32    if let Value::Object(map) = v {
33        let filtered: serde_json::Map<String, Value> = map
34            .into_iter()
35            .filter(|(_, val)| !is_empty_signal(val))
36            .collect();
37        Value::Object(filtered)
38    } else {
39        v
40    }
41}
42
43fn is_empty_signal(v: &Value) -> bool {
44    match v {
45        Value::Null => true,
46        Value::Array(a) => a.is_empty(),
47        Value::Object(o) => o.is_empty(),
48        _ => false,
49    }
50}
51
52/// Recursive variant of `drop_empty_top_level`. Walks every nested
53/// object and drops null/[]/{} children. Arrays' elements are
54/// recursed into (an object inside an array still gets cleaned)
55/// but the array itself is preserved at its length — position
56/// often matters in arrays.
57///
58/// AcmeFlow refinement #1 (1.0.84): applied selectively to
59/// `safe_change_recipe` in prepare-change responses. Without this,
60/// `safe_change_recipe.preserve:[]`, `reference_only:[]`,
61/// `likely_omitted_files:[]` etc. bloat the response on the
62/// common case where the recipe has nothing to preserve / nothing
63/// reference-only / nothing omitted.
64///
65/// Use sparingly outside of known-noisy subtrees — an empty array
66/// inside a less-known object COULD be load-bearing semantics
67/// ("explicit empty list" vs "no list"). For shapes you control
68/// and know to be noise, this is the right tool.
69pub fn drop_empty_recursive(v: Value) -> Value {
70    match v {
71        Value::Object(map) => {
72            let cleaned: serde_json::Map<String, Value> = map
73                .into_iter()
74                .map(|(k, val)| (k, drop_empty_recursive(val)))
75                .filter(|(_, val)| !is_empty_signal(val))
76                .collect();
77            Value::Object(cleaned)
78        }
79        Value::Array(arr) => {
80            // Recurse into elements but don't drop empty array
81            // entries — position in arrays carries meaning.
82            Value::Array(arr.into_iter().map(drop_empty_recursive).collect())
83        }
84        other => other,
85    }
86}
87
88/// Skip a numeric counter when it's zero. Useful for fields like
89/// `surfaced`, `entries_applied`, etc. — zero on these means "nothing
90/// happened on this axis," which is the default state the agent can
91/// infer from absence.
92pub fn is_zero_usize(n: &usize) -> bool {
93    *n == 0
94}
95
96/// Skip a BTreeMap<String, usize> when ALL its values are zero.
97/// Used for `by_kind` / `by_kind_dropped` etc. — when every count
98/// is zero the map is pure noise.
99pub fn is_all_zero_string_usize_map(m: &BTreeMap<String, usize>) -> bool {
100    m.values().all(|v| *v == 0)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn zero_usize_skipped() {
109        assert!(is_zero_usize(&0));
110        assert!(!is_zero_usize(&1));
111    }
112
113    #[test]
114    fn empty_map_is_all_zero() {
115        let m: BTreeMap<String, usize> = BTreeMap::new();
116        assert!(is_all_zero_string_usize_map(&m));
117    }
118
119    #[test]
120    fn all_zero_map_is_skipped() {
121        let mut m = BTreeMap::new();
122        m.insert("a".into(), 0usize);
123        m.insert("b".into(), 0);
124        assert!(is_all_zero_string_usize_map(&m));
125    }
126
127    #[test]
128    fn map_with_any_nonzero_value_is_not_skipped() {
129        let mut m = BTreeMap::new();
130        m.insert("a".into(), 0usize);
131        m.insert("b".into(), 1);
132        assert!(!is_all_zero_string_usize_map(&m));
133    }
134
135    #[test]
136    fn drop_empty_removes_null_empty_array_empty_object() {
137        let v = serde_json::json!({
138            "kept": 1,
139            "kept_str": "value",
140            "drop_null": serde_json::Value::Null,
141            "drop_empty_arr": [],
142            "drop_empty_obj": {},
143            "kept_arr": [1],
144            "kept_obj": {"x": 1},
145            "kept_zero": 0,         // 0 is signal; skip predicates handle it elsewhere
146            "kept_false": false,    // booleans are signal
147        });
148        let out = drop_empty_top_level(v);
149        let obj = out.as_object().unwrap();
150        assert!(obj.contains_key("kept"));
151        assert!(obj.contains_key("kept_str"));
152        assert!(obj.contains_key("kept_arr"));
153        assert!(obj.contains_key("kept_obj"));
154        assert!(obj.contains_key("kept_zero"));
155        assert!(obj.contains_key("kept_false"));
156        assert!(!obj.contains_key("drop_null"));
157        assert!(!obj.contains_key("drop_empty_arr"));
158        assert!(!obj.contains_key("drop_empty_obj"));
159    }
160
161    #[test]
162    fn drop_empty_only_touches_top_level() {
163        // Nested empty arrays/objects are LOAD-BEARING — keep them.
164        let v = serde_json::json!({
165            "wrapper": { "nested_empty": [] }
166        });
167        let out = drop_empty_top_level(v);
168        // wrapper kept (non-empty object); nested empty array stays.
169        assert!(out["wrapper"]["nested_empty"].is_array());
170    }
171
172    #[test]
173    fn drop_empty_no_op_on_non_object() {
174        let v = serde_json::json!([1, 2, 3]);
175        let out = drop_empty_top_level(v.clone());
176        assert_eq!(out, v);
177    }
178
179    #[test]
180    fn drop_empty_recursive_strips_nested_empties() {
181        // AcmeFlow refinement #1 (1.0.84): the recursive
182        // variant walks into nested objects. safe_change_recipe
183        // shape — many empty sibling arrays inside the recipe
184        // object.
185        let v = serde_json::json!({
186            "safe_change_recipe": {
187                "inspect": [{"file": "a.py"}],
188                "preserve": [],
189                "reference_only": [],
190                "likely_omitted_files": [],
191                "blast_radius": {
192                    "total_callers": 95,
193                    "callee_layer_distribution": {},
194                }
195            }
196        });
197        let out = drop_empty_recursive(v);
198        let recipe = &out["safe_change_recipe"];
199        assert!(recipe["inspect"].is_array());
200        assert!(recipe.get("preserve").is_none(), "empty preserve dropped");
201        assert!(
202            recipe.get("reference_only").is_none(),
203            "empty ref_only dropped"
204        );
205        assert!(
206            recipe.get("likely_omitted_files").is_none(),
207            "empty likely_omitted dropped"
208        );
209        assert!(
210            recipe["blast_radius"]["total_callers"].is_number(),
211            "non-empty kept"
212        );
213        assert!(
214            recipe["blast_radius"]
215                .get("callee_layer_distribution")
216                .is_none(),
217            "nested empty object dropped"
218        );
219    }
220
221    #[test]
222    fn drop_empty_recursive_preserves_array_positions() {
223        // Arrays of objects: recurse INTO each element but don't
224        // drop empty elements (position matters).
225        let v = serde_json::json!({
226            "items": [
227                {"keep": 1, "drop_null": null},
228                {"keep": 2, "drop_empty_obj": {}}
229            ]
230        });
231        let out = drop_empty_recursive(v);
232        let items = out["items"].as_array().unwrap();
233        assert_eq!(items.len(), 2, "array length preserved");
234        assert_eq!(items[0]["keep"], 1);
235        assert!(items[0].get("drop_null").is_none());
236        assert_eq!(items[1]["keep"], 2);
237        assert!(items[1].get("drop_empty_obj").is_none());
238    }
239}