Skip to main content

gglib_core/normalize/
history.rs

1//! Cross-turn "thinking debt" removal for chat history.
2//!
3//! Small reasoning models (e.g. Qwen3.5-4B) have a strong tendency to
4//! pattern-match their own previous `<think>` traces in the conversation
5//! history and produce an unbounded thinking stream that never closes —
6//! the model sees prior reasoning trails and tries to extend them. The
7//! reference fix, mirroring `OpenAI`'s native behavior, is to drop reasoning
8//! artifacts from prior assistant turns before the model sees them.
9//!
10//! This module is the **single source of truth** for that scrub. Every
11//! surface that builds a chat-completion request body must pipe its
12//! `messages` array through [`strip_thinking_debt`] so the proxy, the
13//! in-process agent loop (CLI / Tauri), and any future direct-mode
14//! consumer all benefit equally.
15//!
16//! The transform is:
17//!
18//! * Unconditional — there is no per-model gate. Non-reasoning messages
19//!   simply have nothing to strip and pass through untouched.
20//! * Defensive — only assistant messages are touched; user, system, tool,
21//!   and developer messages are never modified.
22//! * The same in either shape — the `reasoning_content` key is removed
23//!   outright, and every `<think>...</think>` block is excised from the
24//!   text of `content`, whether that is a string or the array of parts
25//!   VS Code's gateway sends; parts that are not text are left as they
26//!   are, and so is `content` of any other shape. A text that lost a
27//!   block is also trimmed of surrounding whitespace, as string content
28//!   always was; a text that had none is untouched, whitespace included.
29//!   The walk over both shapes is
30//!   [`request_pipeline::for_each_text_mut`](crate::request_pipeline::for_each_text_mut).
31//! * Forward-safe on unclosed tags — an unclosed `<think>` from the most
32//!   recent turn is preserved verbatim; the upstream is responsible for
33//!   closing it.
34
35use serde_json::Value;
36
37use crate::request_pipeline::for_each_text_mut;
38
39/// Strip reasoning artifacts from prior assistant messages in `messages`.
40///
41/// Returns the number of assistant entries that were modified. A return
42/// value of `0` means the caller can safely skip any re-serialization
43/// step.
44///
45/// See the module docs for the exact rules.
46pub fn strip_thinking_debt(messages: &mut [Value]) -> usize {
47    let mut touched = 0usize;
48    for msg in messages.iter_mut() {
49        let Some(obj) = msg.as_object_mut() else {
50            continue;
51        };
52        let is_assistant = obj
53            .get("role")
54            .and_then(|r| r.as_str())
55            .is_some_and(|r| r == "assistant");
56        if !is_assistant {
57            continue;
58        }
59
60        let removed_reasoning = obj.remove("reasoning_content").is_some();
61        let stripped_inline = obj.get_mut("content").is_some_and(|content| {
62            let mut stripped = false;
63            for_each_text_mut(content, &mut |text| {
64                if let Some(new_text) = strip_think_blocks(text) {
65                    *text = new_text;
66                    stripped = true;
67                }
68            });
69            stripped
70        });
71
72        if removed_reasoning || stripped_inline {
73            touched += 1;
74        }
75    }
76    touched
77}
78
79/// Remove every `<think>...</think>` block from `s`.
80///
81/// Returns `Some(new_string)` when at least one block was removed,
82/// otherwise `None` so the caller can avoid a needless allocation.
83/// Matching is case-sensitive: each `<think>` is paired with the next
84/// `</think>` that follows it. An unclosed `<think>` is left intact (the
85/// upstream model is responsible for closing it).
86fn strip_think_blocks(s: &str) -> Option<String> {
87    const OPEN: &str = "<think>";
88    const CLOSE: &str = "</think>";
89
90    if !s.contains(OPEN) {
91        return None;
92    }
93
94    let mut out = String::with_capacity(s.len());
95    let mut rest = s;
96    let mut changed = false;
97    while let Some(open_idx) = rest.find(OPEN) {
98        let after_open = &rest[open_idx + OPEN.len()..];
99        let Some(close_off) = after_open.find(CLOSE) else {
100            // Unclosed <think>: keep verbatim, stop scanning.
101            break;
102        };
103        out.push_str(&rest[..open_idx]);
104        rest = &after_open[close_off + CLOSE.len()..];
105        changed = true;
106    }
107    if !changed {
108        return None;
109    }
110    out.push_str(rest);
111    Some(out.trim().to_string())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use serde_json::json;
118
119    fn msgs(v: Value) -> Vec<Value> {
120        match v {
121            Value::Array(a) => a,
122            other => panic!("expected array, got {other:?}"),
123        }
124    }
125
126    #[test]
127    fn strip_removes_reasoning_content_from_assistant_message() {
128        let mut m = msgs(json!([
129            {"role": "user", "content": "hi"},
130            {"role": "assistant", "content": "hello", "reasoning_content": "long ramble..."}
131        ]));
132        let touched = strip_thinking_debt(&mut m);
133        assert_eq!(touched, 1);
134        assert_eq!(m[1]["content"], "hello");
135        assert!(m[1].get("reasoning_content").is_none());
136        assert_eq!(m[0]["content"], "hi");
137    }
138
139    #[test]
140    fn strip_removes_inline_think_blocks_from_assistant_content() {
141        let mut m = msgs(json!([
142            {"role": "assistant", "content": "<think>secret\nplan</think>The answer is 42."}
143        ]));
144        let touched = strip_thinking_debt(&mut m);
145        assert_eq!(touched, 1);
146        assert_eq!(m[0]["content"], "The answer is 42.");
147    }
148
149    #[test]
150    fn strip_handles_multiple_think_blocks() {
151        let mut m = msgs(json!([
152            {"role": "assistant", "content": "<think>a</think>between<think>b</think>after"}
153        ]));
154        strip_thinking_debt(&mut m);
155        assert_eq!(m[0]["content"], "betweenafter");
156    }
157
158    #[test]
159    fn strip_leaves_unclosed_think_intact() {
160        let mut m = msgs(json!([
161            {"role": "assistant", "content": "<think>still going..."}
162        ]));
163        let touched = strip_thinking_debt(&mut m);
164        assert_eq!(touched, 0);
165        assert_eq!(m[0]["content"], "<think>still going...");
166    }
167
168    #[test]
169    fn strip_does_not_touch_user_or_system_or_tool_messages() {
170        let original = json!([
171            {"role": "system", "content": "<think>policy</think>be helpful", "reasoning_content": "x"},
172            {"role": "user", "content": "<think>ignore</think>question", "reasoning_content": "y"},
173            {"role": "tool", "content": "<think>tool</think>result", "tool_call_id": "c1", "reasoning_content": "z"}
174        ]);
175        let mut m = msgs(original.clone());
176        let touched = strip_thinking_debt(&mut m);
177        assert_eq!(touched, 0);
178        assert_eq!(Value::Array(m), original);
179    }
180
181    #[test]
182    fn strip_handles_empty_messages_array() {
183        let mut m: Vec<Value> = Vec::new();
184        let touched = strip_thinking_debt(&mut m);
185        assert_eq!(touched, 0);
186        assert!(m.is_empty());
187    }
188
189    #[test]
190    fn strip_skips_when_nothing_to_remove() {
191        let original = json!([
192            {"role": "assistant", "content": "plain answer"}
193        ]);
194        let mut m = msgs(original.clone());
195        let touched = strip_thinking_debt(&mut m);
196        assert_eq!(touched, 0);
197        assert_eq!(Value::Array(m), original);
198    }
199
200    #[test]
201    fn strip_removes_think_blocks_from_array_form_content() {
202        // The array of parts VS Code's gateway sends is stripped like a
203        // string: every text part loses its blocks, a part that is not text
204        // is untouched, and the shape is kept (#1077).
205        // The last part keeps its whitespace: only a text that lost a block
206        // is trimmed, as string content always was.
207        let mut m = msgs(json!([
208            {
209                "role": "assistant",
210                "content": [
211                    {"type": "text", "text": "<think>x</think>hi"},
212                    {"type": "image_url", "image_url": {"url": "data:,"}},
213                    {"type": "text", "text": " <think>y</think> there "},
214                    {"type": "text", "text": " plain "}
215                ],
216                "reasoning_content": "r"
217            }
218        ]));
219        let touched = strip_thinking_debt(&mut m);
220        assert_eq!(touched, 1);
221        assert!(m[0].get("reasoning_content").is_none());
222        assert_eq!(
223            m[0]["content"],
224            json!([
225                {"type": "text", "text": "hi"},
226                {"type": "image_url", "image_url": {"url": "data:,"}},
227                {"type": "text", "text": "there"},
228                {"type": "text", "text": " plain "}
229            ])
230        );
231    }
232
233    #[test]
234    fn strip_counts_an_array_form_message_only_when_a_block_went() {
235        // A clean array is not "touched", so a caller can skip re-serialising.
236        let original = json!([
237            {"role": "assistant", "content": [{"type": "text", "text": "plain"}]}
238        ]);
239        let mut m = msgs(original.clone());
240        assert_eq!(strip_thinking_debt(&mut m), 0);
241        assert_eq!(Value::Array(m), original);
242    }
243
244    #[test]
245    fn strip_skips_non_object_messages() {
246        // Defensive: a stray non-object entry should not panic.
247        let mut m = vec![
248            Value::String("garbage".to_string()),
249            json!({
250                "role": "assistant",
251                "reasoning_content": "drop me"
252            }),
253        ];
254        let touched = strip_thinking_debt(&mut m);
255        assert_eq!(touched, 1);
256        assert!(m[1].get("reasoning_content").is_none());
257    }
258
259    #[test]
260    fn strip_handles_assistant_without_role_string() {
261        // role is a number — defensively treat as not assistant.
262        let mut m = msgs(json!([
263            {"role": 7, "content": "<think>x</think>y", "reasoning_content": "r"}
264        ]));
265        let touched = strip_thinking_debt(&mut m);
266        assert_eq!(touched, 0);
267        assert_eq!(m[0]["reasoning_content"], "r");
268    }
269
270    #[test]
271    fn strip_handles_assistant_with_only_inline_think() {
272        // reasoning_content absent, but inline <think> present.
273        let mut m = msgs(json!([
274            {"role": "assistant", "content": "<think>a</think>b"}
275        ]));
276        let touched = strip_thinking_debt(&mut m);
277        assert_eq!(touched, 1);
278        assert_eq!(m[0]["content"], "b");
279    }
280}