gglib_core/normalize/
history.rs1use serde_json::Value;
30
31pub fn strip_thinking_debt(messages: &mut [Value]) -> usize {
39 let mut touched = 0usize;
40 for msg in messages.iter_mut() {
41 let Some(obj) = msg.as_object_mut() else {
42 continue;
43 };
44 let is_assistant = obj
45 .get("role")
46 .and_then(|r| r.as_str())
47 .is_some_and(|r| r == "assistant");
48 if !is_assistant {
49 continue;
50 }
51
52 let removed_reasoning = obj.remove("reasoning_content").is_some();
53 let stripped_inline = if let Some(Value::String(s)) = obj.get_mut("content") {
54 strip_think_blocks(s).is_some_and(|new_s| {
55 *s = new_s;
56 true
57 })
58 } else {
59 false
60 };
61
62 if removed_reasoning || stripped_inline {
63 touched += 1;
64 }
65 }
66 touched
67}
68
69fn strip_think_blocks(s: &str) -> Option<String> {
77 const OPEN: &str = "<think>";
78 const CLOSE: &str = "</think>";
79
80 if !s.contains(OPEN) {
81 return None;
82 }
83
84 let mut out = String::with_capacity(s.len());
85 let mut rest = s;
86 let mut changed = false;
87 while let Some(open_idx) = rest.find(OPEN) {
88 let after_open = &rest[open_idx + OPEN.len()..];
89 let Some(close_off) = after_open.find(CLOSE) else {
90 break;
92 };
93 out.push_str(&rest[..open_idx]);
94 rest = &after_open[close_off + CLOSE.len()..];
95 changed = true;
96 }
97 if !changed {
98 return None;
99 }
100 out.push_str(rest);
101 Some(out.trim().to_string())
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use serde_json::json;
108
109 fn msgs(v: Value) -> Vec<Value> {
110 match v {
111 Value::Array(a) => a,
112 other => panic!("expected array, got {other:?}"),
113 }
114 }
115
116 #[test]
117 fn strip_removes_reasoning_content_from_assistant_message() {
118 let mut m = msgs(json!([
119 {"role": "user", "content": "hi"},
120 {"role": "assistant", "content": "hello", "reasoning_content": "long ramble..."}
121 ]));
122 let touched = strip_thinking_debt(&mut m);
123 assert_eq!(touched, 1);
124 assert_eq!(m[1]["content"], "hello");
125 assert!(m[1].get("reasoning_content").is_none());
126 assert_eq!(m[0]["content"], "hi");
127 }
128
129 #[test]
130 fn strip_removes_inline_think_blocks_from_assistant_content() {
131 let mut m = msgs(json!([
132 {"role": "assistant", "content": "<think>secret\nplan</think>The answer is 42."}
133 ]));
134 let touched = strip_thinking_debt(&mut m);
135 assert_eq!(touched, 1);
136 assert_eq!(m[0]["content"], "The answer is 42.");
137 }
138
139 #[test]
140 fn strip_handles_multiple_think_blocks() {
141 let mut m = msgs(json!([
142 {"role": "assistant", "content": "<think>a</think>between<think>b</think>after"}
143 ]));
144 strip_thinking_debt(&mut m);
145 assert_eq!(m[0]["content"], "betweenafter");
146 }
147
148 #[test]
149 fn strip_leaves_unclosed_think_intact() {
150 let mut m = msgs(json!([
151 {"role": "assistant", "content": "<think>still going..."}
152 ]));
153 let touched = strip_thinking_debt(&mut m);
154 assert_eq!(touched, 0);
155 assert_eq!(m[0]["content"], "<think>still going...");
156 }
157
158 #[test]
159 fn strip_does_not_touch_user_or_system_or_tool_messages() {
160 let original = json!([
161 {"role": "system", "content": "<think>policy</think>be helpful", "reasoning_content": "x"},
162 {"role": "user", "content": "<think>ignore</think>question", "reasoning_content": "y"},
163 {"role": "tool", "content": "<think>tool</think>result", "tool_call_id": "c1", "reasoning_content": "z"}
164 ]);
165 let mut m = msgs(original.clone());
166 let touched = strip_thinking_debt(&mut m);
167 assert_eq!(touched, 0);
168 assert_eq!(Value::Array(m), original);
169 }
170
171 #[test]
172 fn strip_handles_empty_messages_array() {
173 let mut m: Vec<Value> = Vec::new();
174 let touched = strip_thinking_debt(&mut m);
175 assert_eq!(touched, 0);
176 assert!(m.is_empty());
177 }
178
179 #[test]
180 fn strip_skips_when_nothing_to_remove() {
181 let original = json!([
182 {"role": "assistant", "content": "plain answer"}
183 ]);
184 let mut m = msgs(original.clone());
185 let touched = strip_thinking_debt(&mut m);
186 assert_eq!(touched, 0);
187 assert_eq!(Value::Array(m), original);
188 }
189
190 #[test]
191 fn strip_preserves_non_string_content() {
192 let mut m = msgs(json!([
195 {
196 "role": "assistant",
197 "content": [{"type": "text", "text": "<think>x</think>hi"}],
198 "reasoning_content": "r"
199 }
200 ]));
201 let touched = strip_thinking_debt(&mut m);
202 assert_eq!(touched, 1);
203 assert!(m[0].get("reasoning_content").is_none());
204 assert_eq!(m[0]["content"][0]["text"], "<think>x</think>hi");
205 }
206
207 #[test]
208 fn strip_skips_non_object_messages() {
209 let mut m = vec![
211 Value::String("garbage".to_string()),
212 json!({
213 "role": "assistant",
214 "reasoning_content": "drop me"
215 }),
216 ];
217 let touched = strip_thinking_debt(&mut m);
218 assert_eq!(touched, 1);
219 assert!(m[1].get("reasoning_content").is_none());
220 }
221
222 #[test]
223 fn strip_handles_assistant_without_role_string() {
224 let mut m = msgs(json!([
226 {"role": 7, "content": "<think>x</think>y", "reasoning_content": "r"}
227 ]));
228 let touched = strip_thinking_debt(&mut m);
229 assert_eq!(touched, 0);
230 assert_eq!(m[0]["reasoning_content"], "r");
231 }
232
233 #[test]
234 fn strip_handles_assistant_with_only_inline_think() {
235 let mut m = msgs(json!([
237 {"role": "assistant", "content": "<think>a</think>b"}
238 ]));
239 let touched = strip_thinking_debt(&mut m);
240 assert_eq!(touched, 1);
241 assert_eq!(m[0]["content"], "b");
242 }
243}