Skip to main content

newt_core/
reasoning.rs

1//! Separating a model's **reasoning** from its **content**.
2//!
3//! Thinking models (Nemotron with `detailed thinking on`, DeepSeek-R1, Qwen3, …)
4//! emit their chain-of-thought *inline* in the content stream, wrapped in
5//! `<think>…</think>` tags — distinct from the Ollama/OpenAI *separate* reasoning
6//! field the loop already detects. Left in place, that reasoning leaks into the
7//! reply shown to the user and into the content the agentic loop parses (issue
8//! #385). This module strips it.
9//!
10//! It's a **split**, not just a strip: [`split_reasoning`] returns
11//! `(content, reasoning)` so the reasoning can be *captured* (the `plan_mode`
12//! technique, `docs/design/thinking-effort-and-plan-mode.md`) rather than thrown
13//! away — today every caller discards the reasoning half, matching prior behavior.
14//!
15//! Two surfaces:
16//! - [`split_reasoning`] — batch, for a fully-assembled reply (the non-streaming
17//!   completion paths).
18//! - [`ThinkFilter`] — incremental, for the streaming path, where a `<think>` block
19//!   spans token boundaries (`<thi` / `nk>…</thi` / `nk>`) and must be suppressed
20//!   live without ever printing a partial tag.
21
22const OPEN: &str = "<think>";
23const CLOSE: &str = "</think>";
24
25/// Split inline `<think>…</think>` reasoning out of `content`, returning
26/// `(clean_content, reasoning)`.
27///
28/// Handles the shapes seen in the wild:
29/// - paired `<think>R</think>A` (one or many, anywhere) → reasoning removed;
30/// - a **lone leading** `R</think>A` (some templates start mid-thought, emitting
31///   only the closer) → everything up to the first `</think>` is reasoning;
32/// - an **unterminated** `A<think>R` (a truncated thinking block) → everything from
33///   `<think>` on is reasoning.
34///
35/// **No-op fast path:** content with no `<think>`/`</think>` is returned unchanged
36/// (not even trimmed), so ordinary replies are bit-for-bit untouched.
37#[must_use]
38pub fn split_reasoning(content: &str) -> (String, Option<String>) {
39    // Lone leading closer (no opener) — only meaningful when there is no `<think>`.
40    if !content.contains(OPEN) {
41        return match content.find(CLOSE) {
42            Some(close) => {
43                let reasoning = content[..close].trim();
44                let clean = content[close + CLOSE.len()..].trim();
45                (clean.to_string(), non_empty(reasoning))
46            }
47            None => (content.to_string(), None), // nothing to strip — untouched
48        };
49    }
50
51    let mut clean = String::new();
52    let mut reasoning = String::new();
53    let mut rest = content;
54    while let Some(open) = rest.find(OPEN) {
55        clean.push_str(&rest[..open]);
56        let after = &rest[open + OPEN.len()..];
57        match after.find(CLOSE) {
58            Some(close) => {
59                reasoning.push_str(&after[..close]);
60                rest = &after[close + CLOSE.len()..];
61            }
62            None => {
63                // Unterminated <think>: the remainder is all reasoning.
64                reasoning.push_str(after);
65                rest = "";
66                break;
67            }
68        }
69    }
70    clean.push_str(rest);
71    (clean.trim().to_string(), non_empty(reasoning.trim()))
72}
73
74fn non_empty(s: &str) -> Option<String> {
75    if s.is_empty() {
76        None
77    } else {
78        Some(s.to_string())
79    }
80}
81
82/// An incremental filter that suppresses `<think>…</think>` spans from a *streamed*
83/// content sequence, emitting only the clean (non-reasoning) text — even when a tag
84/// is split across feeds.
85///
86/// Feed each streamed token through [`feed`](Self::feed) and print/accumulate what
87/// it returns; call [`finish`](Self::finish) once at the end. A trailing partial tag
88/// (`…<thi`) is held back rather than emitted, so a tag is never printed in pieces.
89#[derive(Debug, Default)]
90pub struct ThinkFilter {
91    inside: bool,
92    /// `inside` was *assumed* (lone-leading-closer mode) rather than entered via
93    /// a literal `<think>`, so buffered content is only provisionally reasoning:
94    /// if no `</think>` ever arrives it is flushed as clean (the model wasn't
95    /// actually emitting reasoning), so a reply is never lost. See
96    /// [`with_leading_reasoning`](Self::with_leading_reasoning).
97    implicit: bool,
98    buf: String,
99}
100
101impl ThinkFilter {
102    /// A fresh filter (outside any think block).
103    #[must_use]
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// A filter that begins *inside* a reasoning block — for models that stream
109    /// the **lone-leading-closer** shape (`reasoning</think>answer`, with no
110    /// opening `<think>`), e.g. Nemotron with `detailed thinking on`. Everything
111    /// up to the first `</think>` is treated as reasoning, matching
112    /// [`split_reasoning`]'s batch handling of the same shape (#528).
113    ///
114    /// The leading block is held *provisionally*: if a `</think>` never arrives
115    /// (the model wasn't actually reasoning — e.g. thinking turned off), the
116    /// buffered text is flushed as clean by [`finish`](Self::finish), so a reply
117    /// is never dropped. Gate via [`emits_leading_reasoning`].
118    #[must_use]
119    pub fn with_leading_reasoning() -> Self {
120        Self {
121            inside: true,
122            implicit: true,
123            buf: String::new(),
124        }
125    }
126
127    /// Feed one streamed token; returns the clean text to emit now (may be empty).
128    pub fn feed(&mut self, token: &str) -> String {
129        self.feed_split(token).0
130    }
131
132    /// Feed one streamed token; returns `(clean, reasoning)` for this feed — the
133    /// clean text to emit now AND the reasoning text suppressed from it (either
134    /// may be empty). Lets a caller *render* the live reasoning (the cargo-style
135    /// thinking spinner) instead of discarding it, while `feed` keeps the
136    /// suppress-only behavior for everyone else.
137    pub fn feed_split(&mut self, token: &str) -> (String, String) {
138        self.buf.push_str(token);
139        let mut clean = String::new();
140        let mut reasoning = String::new();
141        loop {
142            if self.inside {
143                match self.buf.find(CLOSE) {
144                    Some(i) => {
145                        reasoning.push_str(&self.buf[..i]);
146                        self.buf.drain(..i + CLOSE.len());
147                        self.inside = false; // continue: there may be clean text after
148                        self.implicit = false; // the closer resolved a leading block
149                    }
150                    None => {
151                        if self.implicit {
152                            // Assumed leading reasoning: keep everything buffered
153                            // until a closer proves it was reasoning. finish()
154                            // flushes it as clean if none ever arrives, so a
155                            // non-thinking reply is not lost.
156                            break;
157                        }
158                        // Suppress reasoning, but keep a trailing partial `</think>`.
159                        let cut = safe_len(&self.buf, CLOSE);
160                        reasoning.push_str(&self.buf[..cut]);
161                        self.buf.drain(..cut);
162                        break;
163                    }
164                }
165            } else {
166                match self.buf.find(OPEN) {
167                    Some(i) => {
168                        clean.push_str(&self.buf[..i]);
169                        self.buf.drain(..i + OPEN.len());
170                        self.inside = true;
171                    }
172                    None => {
173                        // Emit all but a trailing partial `<think>`.
174                        let cut = safe_len(&self.buf, OPEN);
175                        clean.push_str(&self.buf[..cut]);
176                        self.buf.drain(..cut);
177                        break;
178                    }
179                }
180            }
181        }
182        (clean, reasoning)
183    }
184
185    /// Flush at end of stream: emits any buffered clean tail (an unterminated
186    /// `<think>` leaves its reasoning suppressed).
187    pub fn finish(&mut self) -> String {
188        // A real unterminated `<think>` suppresses its reasoning; an *assumed*
189        // (implicit) leading block that never closed turned out to be the reply
190        // itself, so flush it as clean — no data loss for a non-thinking model.
191        let out = if self.inside && !self.implicit {
192            String::new()
193        } else {
194            std::mem::take(&mut self.buf)
195        };
196        self.buf.clear();
197        self.inside = false;
198        self.implicit = false;
199        out
200    }
201}
202
203/// Whether a model streams its chain-of-thought as a **lone leading closer**
204/// (`reasoning</think>answer`, with no opening `<think>`) — the shape Nemotron
205/// (`detailed thinking on`), DeepSeek-R1, and Qwen3 emit. Such models need
206/// [`ThinkFilter::with_leading_reasoning`] so the streamed reasoning (and the
207/// bare `</think>`) is suppressed rather than printed into the reply.
208///
209/// Name-based stopgap until a per-model capability card carries this flag
210/// (#384); matched case-insensitively against the known families.
211#[must_use]
212pub fn emits_leading_reasoning(model: &str) -> bool {
213    let m = model.to_ascii_lowercase();
214    ["nemotron", "deepseek-r1", "qwen3"]
215        .iter()
216        .any(|fam| m.contains(fam))
217}
218
219/// The length of `buf` that is safe to commit (emit *or* discard) without splitting
220/// a possible `tag` — i.e. `buf` minus its longest suffix that is a proper prefix of
221/// `tag`. (`"a<thi"`, tag `"<think>"` → `1`, holding back `"<thi"`.)
222fn safe_len(buf: &str, tag: &str) -> usize {
223    let max = buf.len().min(tag.len() - 1);
224    for k in (1..=max).rev() {
225        let start = buf.len() - k;
226        if buf.is_char_boundary(start) && tag.starts_with(&buf[start..]) {
227            return start;
228        }
229    }
230    buf.len()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn no_tags_is_untouched() {
239        let (c, r) = split_reasoning("just a normal reply");
240        assert_eq!(c, "just a normal reply");
241        assert!(r.is_none());
242    }
243
244    #[test]
245    fn strips_a_leading_paired_block() {
246        let (c, r) = split_reasoning("<think>let me think</think>\n\nThe answer is 42.");
247        assert_eq!(c, "The answer is 42.");
248        assert_eq!(r.as_deref(), Some("let me think"));
249    }
250
251    #[test]
252    fn strips_multiple_and_embedded_blocks() {
253        let (c, r) = split_reasoning("A<think>r1</think>B<think>r2</think>C");
254        assert_eq!(c, "ABC");
255        assert_eq!(r.as_deref(), Some("r1r2"));
256    }
257
258    #[test]
259    fn lone_leading_closer_is_reasoning() {
260        let (c, r) = split_reasoning("reasoning with no opener</think>the answer");
261        assert_eq!(c, "the answer");
262        assert_eq!(r.as_deref(), Some("reasoning with no opener"));
263    }
264
265    #[test]
266    fn unterminated_open_swallows_the_tail() {
267        let (c, r) = split_reasoning("partial answer<think>cut off mid-thought");
268        assert_eq!(c, "partial answer");
269        assert_eq!(r.as_deref(), Some("cut off mid-thought"));
270    }
271
272    #[test]
273    fn all_reasoning_yields_empty_content() {
274        let (c, r) = split_reasoning("<think>only thinking, no answer</think>");
275        assert_eq!(c, "");
276        assert_eq!(r.as_deref(), Some("only thinking, no answer"));
277    }
278
279    /// Drive the streaming filter with an arbitrary token split and assert it equals
280    /// the batch result's clean half.
281    fn stream(content: &str, tokens: &[&str]) -> String {
282        let mut f = ThinkFilter::new();
283        let mut out = String::new();
284        for t in tokens {
285            out.push_str(&f.feed(t));
286        }
287        out.push_str(&f.finish());
288        let _ = content;
289        out
290    }
291
292    #[test]
293    fn streaming_suppresses_a_block_split_across_tokens() {
294        // The <think> tags are deliberately shredded across token boundaries.
295        let tokens = [
296            "Here",
297            " <thi",
298            "nk>my ",
299            "reason",
300            "ing</thi",
301            "nk> is the ",
302            "answer",
303        ];
304        assert_eq!(stream("", &tokens), "Here  is the answer");
305    }
306
307    #[test]
308    fn streaming_char_by_char_matches_batch() {
309        let content = "<think>step one\nstep two</think>Final line.";
310        let toks: Vec<String> = content.chars().map(|c| c.to_string()).collect();
311        let refs: Vec<&str> = toks.iter().map(String::as_str).collect();
312        assert_eq!(stream(content, &refs), "Final line.");
313    }
314
315    #[test]
316    fn streaming_plain_text_is_verbatim() {
317        assert_eq!(stream("", &["no ", "tags ", "here"]), "no tags here");
318    }
319
320    #[test]
321    fn feed_split_captures_reasoning_across_token_boundaries() {
322        let tokens = [
323            "Here",
324            " <thi",
325            "nk>my ",
326            "reason",
327            "ing</thi",
328            "nk> is the ",
329            "answer",
330        ];
331        let mut f = ThinkFilter::new();
332        let (mut clean, mut reasoning) = (String::new(), String::new());
333        for t in tokens {
334            let (c, r) = f.feed_split(t);
335            clean.push_str(&c);
336            reasoning.push_str(&r);
337        }
338        clean.push_str(&f.finish());
339        assert_eq!(clean, "Here  is the answer");
340        assert_eq!(reasoning, "my reasoning");
341        // `feed` still suppresses (delegates to feed_split's clean half).
342        let mut g = ThinkFilter::new();
343        assert_eq!(g.feed("a<think>b"), "a");
344        assert_eq!(g.feed("c</think>d"), "d");
345    }
346
347    #[test]
348    fn streaming_unterminated_block_is_dropped() {
349        assert_eq!(
350            stream("", &["answer ", "<think>still ", "thinking"]),
351            "answer "
352        );
353    }
354
355    /// Drive the leading-reasoning filter; returns `(clean, reasoning)`.
356    fn stream_leading(tokens: &[&str]) -> (String, String) {
357        let mut f = ThinkFilter::with_leading_reasoning();
358        let (mut clean, mut reasoning) = (String::new(), String::new());
359        for t in tokens {
360            let (c, r) = f.feed_split(t);
361            clean.push_str(&c);
362            reasoning.push_str(&r);
363        }
364        clean.push_str(&f.finish());
365        (clean, reasoning)
366    }
367
368    #[test]
369    fn leading_reasoning_strips_lone_closer() {
370        // The Nemotron shape: reasoning, a bare `</think>`, then the answer —
371        // no opening `<think>`. Matches the batch `lone_leading_closer_is_reasoning`.
372        let (clean, reasoning) = stream_leading(&["reasoning", "</think>", "the answer"]);
373        assert_eq!(clean, "the answer");
374        assert_eq!(reasoning, "reasoning");
375    }
376
377    #[test]
378    fn leading_reasoning_handles_closer_split_across_tokens() {
379        let (clean, reasoning) = stream_leading(&["reason", "ing</thi", "nk>the ", "answer"]);
380        assert_eq!(clean, "the answer");
381        assert_eq!(reasoning, "reasoning");
382    }
383
384    #[test]
385    fn leading_reasoning_without_closer_flushes_as_clean() {
386        // A flagged model that emits no `</think>` (e.g. thinking turned off)
387        // must still get its full reply — provisional reasoning is flushed as
388        // clean, never lost.
389        let (clean, reasoning) = stream_leading(&["a plain ", "reply with ", "no tags"]);
390        assert_eq!(clean, "a plain reply with no tags");
391        assert_eq!(reasoning, "");
392    }
393
394    #[test]
395    fn leading_reasoning_tolerates_a_paired_block() {
396        // Even if a flagged model does emit an opener, the literal tag is folded
397        // into the (discarded) reasoning and the answer stays clean.
398        let (clean, _r) = stream_leading(&["<think>r</think>", "answer"]);
399        assert_eq!(clean, "answer");
400    }
401
402    #[test]
403    fn emits_leading_reasoning_matches_known_families() {
404        assert!(emits_leading_reasoning("nemotron-3-nano:30b"));
405        assert!(emits_leading_reasoning("nemotron3:33b"));
406        assert!(emits_leading_reasoning("deepseek-r1:7b"));
407        assert!(emits_leading_reasoning("qwen3:8b"));
408        assert!(!emits_leading_reasoning("llama3:8b"));
409        assert!(!emits_leading_reasoning("qwen2.5-coder:7b"));
410        assert!(!emits_leading_reasoning("gpt-4o"));
411    }
412}