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}