Skip to main content

vtcode_llm/providers/
reasoning.rs

1use serde_json::Value;
2
3#[derive(Default, Clone)]
4pub struct ReasoningBuffer {
5    text: String,
6    /// Byte offset of the last pushed chunk within `text`, used for
7    /// consecutive-duplicate detection without storing a second owned
8    /// `String`. Since `push` only ever appends to `text`, the slice
9    /// `text[start..]` always refers to the most recent chunk.
10    last_chunk_start: Option<usize>,
11}
12
13impl ReasoningBuffer {
14    #[inline]
15    pub(crate) fn push(&mut self, chunk: &str) -> Option<String> {
16        if chunk.is_empty() {
17            return None;
18        }
19
20        // Compare against the last chunk by slicing `text` at the stored
21        // offset, avoiding a separate owned `String` + clone per token.
22        if let Some(start) = self.last_chunk_start
23            && self.text.get(start..) == Some(chunk)
24        {
25            return None;
26        }
27
28        let start = self.text.len();
29        self.text.push_str(chunk);
30        self.last_chunk_start = Some(start);
31        Some(chunk.to_string())
32    }
33
34    pub(crate) fn finalize(self) -> Option<String> {
35        let trimmed = self.text.trim();
36        if trimmed.is_empty() {
37            None
38        } else {
39            Some(trimmed.to_string())
40        }
41    }
42}
43
44pub fn clean_reasoning_text(text: &str) -> String {
45    vtcode_commons::formatting::clean_reasoning_text(text)
46}
47
48const PRIMARY_TEXT_KEYS: &[&str] = &["text", "content", "reasoning", "thought", "thinking", "value"];
49const SECONDARY_COLLECTION_KEYS: &[&str] = &[
50    "messages", "parts", "items", "entries", "steps", "segments", "records", "output", "outputs", "logs",
51];
52
53const REASONING_TAGS: &[&str] = &["think", "thinking", "reasoning", "analysis", "thought"];
54const ANSWER_TAGS: &[&str] = &["answer", "final"];
55
56#[derive(Clone, Copy, PartialEq, Eq)]
57enum TagCategory {
58    Reasoning,
59    Answer,
60}
61
62struct ParsedTag<'a> {
63    name: &'a str,
64    end_index: usize,
65    category: TagCategory,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ReasoningSegment {
70    pub text: String,
71    pub stage: Option<String>,
72}
73
74impl ReasoningSegment {
75    pub fn new(text: impl Into<String>, stage: Option<String>) -> Self {
76        Self { text: text.into(), stage }
77    }
78}
79
80pub fn extract_reasoning_trace(value: &Value) -> Option<String> {
81    let mut segments = Vec::new();
82    collect_reasoning_segments(value, &mut segments);
83    let combined: Vec<String> = segments.into_iter().map(|s| s.text).collect();
84    let combined = combined.join("\n");
85    let trimmed = combined.trim();
86    if trimmed.is_empty() {
87        None
88    } else {
89        Some(trimmed.to_string())
90    }
91}
92
93fn collect_reasoning_segments(value: &Value, segments: &mut Vec<ReasoningSegment>) {
94    match value {
95        Value::Null => {}
96        Value::Bool(_) | Value::Number(_) => {}
97        Value::String(text) => {
98            let (mut tagged_segments, cleaned) = split_reasoning_from_text(text);
99
100            if !tagged_segments.is_empty() {
101                for segment in tagged_segments.drain(..) {
102                    push_unique_segment(segments, segment);
103                }
104                if let Some(cleaned_text) = cleaned {
105                    let trimmed = cleaned_text.trim();
106                    if !trimmed.is_empty() {
107                        push_unique_segment(segments, ReasoningSegment::new(trimmed, None));
108                    }
109                }
110                return;
111            }
112
113            let trimmed = text.trim();
114            if trimmed.is_empty() {
115                return;
116            }
117
118            push_unique_segment(segments, ReasoningSegment::new(trimmed, None));
119        }
120        Value::Array(items) => {
121            for item in items {
122                collect_reasoning_segments(item, segments);
123            }
124        }
125        Value::Object(map) => {
126            let mut matched_key = false;
127            for key in PRIMARY_TEXT_KEYS {
128                if let Some(nested) = map.get(*key) {
129                    collect_reasoning_segments(nested, segments);
130                    matched_key = true;
131                }
132            }
133
134            if !matched_key {
135                for key in SECONDARY_COLLECTION_KEYS {
136                    if let Some(nested) = map.get(*key) {
137                        collect_reasoning_segments(nested, segments);
138                        matched_key = true;
139                    }
140                }
141            }
142
143            if !matched_key {
144                for nested in map.values() {
145                    if matches!(nested, Value::Array(_) | Value::Object(_)) {
146                        collect_reasoning_segments(nested, segments);
147                    }
148                }
149            }
150        }
151    }
152}
153
154fn push_unique_segment(segments: &mut Vec<ReasoningSegment>, segment: ReasoningSegment) {
155    if segment.text.trim().is_empty() {
156        return;
157    }
158
159    if segments
160        .last()
161        .map(|last| last.text == segment.text && last.stage == segment.stage)
162        .unwrap_or(false)
163    {
164        return;
165    }
166
167    segments.push(segment);
168}
169
170fn parse_start_tag<'a>(lower: &'a str, start: usize) -> Option<ParsedTag<'a>> {
171    let bytes = lower.as_bytes();
172    let mut index = start + 1;
173
174    if index >= lower.len() {
175        return None;
176    }
177
178    match bytes[index] {
179        b'/' | b'!' | b'?' => return None,
180        _ => {}
181    }
182
183    while index < lower.len() && bytes[index].is_ascii_whitespace() {
184        index += 1;
185    }
186
187    if index >= lower.len() {
188        return None;
189    }
190
191    let name_start = index;
192    while index < lower.len() {
193        let ch = bytes[index];
194        if ch == b'>' || ch.is_ascii_whitespace() {
195            break;
196        }
197        index += 1;
198    }
199
200    if index == name_start {
201        return None;
202    }
203
204    let mut end_index = index;
205    while end_index < lower.len() && bytes[end_index] != b'>' {
206        end_index += 1;
207    }
208
209    if end_index >= lower.len() {
210        return None;
211    }
212
213    let name = &lower[name_start..index];
214    let category = if REASONING_TAGS.contains(&name) {
215        TagCategory::Reasoning
216    } else if ANSWER_TAGS.contains(&name) {
217        TagCategory::Answer
218    } else {
219        return None;
220    };
221
222    Some(ParsedTag { name, end_index, category })
223}
224
225pub fn split_reasoning_from_text(text: &str) -> (Vec<ReasoningSegment>, Option<String>) {
226    if text.trim().is_empty() {
227        return (Vec::new(), None);
228    }
229
230    let lower = text.to_ascii_lowercase();
231    let mut segments: Vec<ReasoningSegment> = Vec::new();
232    let mut cleaned = String::new();
233    let mut modified = false;
234    let mut index = 0usize;
235
236    while index < text.len() {
237        let Some(relative) = lower[index..].find('<') else {
238            cleaned.push_str(&text[index..]);
239            break;
240        };
241
242        let open_index = index + relative;
243        cleaned.push_str(&text[index..open_index]);
244
245        if let Some(tag) = parse_start_tag(&lower, open_index) {
246            let content_start = tag.end_index + 1;
247            let close_sequence = format!("</{}>", tag.name);
248
249            if let Some(relative_close) = lower[content_start..].find(&close_sequence) {
250                let content_end = content_start + relative_close;
251                let inner = &text[content_start..content_end];
252
253                match tag.category {
254                    TagCategory::Reasoning => {
255                        modified = true;
256                        let (nested_segments, nested_cleaned) = split_reasoning_from_text(inner);
257
258                        if nested_segments.is_empty() {
259                            let trimmed = inner.trim();
260                            if !trimmed.is_empty() {
261                                // Use the tag name as the stage
262                                push_unique_segment(
263                                    &mut segments,
264                                    ReasoningSegment::new(trimmed, Some(tag.name.to_owned())),
265                                );
266                            }
267                        } else {
268                            for segment in nested_segments {
269                                push_unique_segment(&mut segments, segment);
270                            }
271                            if let Some(cleaned_inner) = nested_cleaned {
272                                let trimmed = cleaned_inner.trim();
273                                if !trimmed.is_empty() {
274                                    push_unique_segment(
275                                        &mut segments,
276                                        ReasoningSegment::new(trimmed, Some(tag.name.to_owned())),
277                                    );
278                                }
279                            }
280                        }
281                    }
282                    TagCategory::Answer => {
283                        modified = true;
284                        let (nested_segments, nested_cleaned) = split_reasoning_from_text(inner);
285                        for segment in nested_segments {
286                            push_unique_segment(&mut segments, segment);
287                        }
288                        if let Some(cleaned_inner) = nested_cleaned {
289                            cleaned.push_str(&cleaned_inner);
290                        } else {
291                            let trimmed = inner.trim();
292                            if !trimmed.is_empty() {
293                                cleaned.push_str(trimmed);
294                            }
295                        }
296                    }
297                }
298
299                index = content_end + close_sequence.len();
300                continue;
301            }
302        }
303
304        cleaned.push('<');
305        index = open_index + 1;
306    }
307
308    if !modified {
309        return (segments, None);
310    }
311
312    let output = if cleaned.trim().is_empty() { None } else { Some(cleaned) };
313
314    (segments, output)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn extracts_text_from_string() {
323        let value = Value::String("  sample reasoning  ".to_string());
324        let extracted = extract_reasoning_trace(&value);
325        assert_eq!(extracted, Some("sample reasoning".to_string()));
326    }
327
328    #[test]
329    fn extracts_text_from_nested_array() {
330        let value = Value::Array(vec![
331            Value::Object(
332                serde_json::json!({
333                    "type": "thinking",
334                    "text": "step one"
335                })
336                .as_object()
337                .unwrap()
338                .clone(),
339            ),
340            Value::Object(
341                serde_json::json!({
342                    "type": "thinking",
343                    "text": "step two"
344                })
345                .as_object()
346                .unwrap()
347                .clone(),
348            ),
349        ]);
350        let extracted = extract_reasoning_trace(&value);
351        assert_eq!(extracted, Some("step one\nstep two".to_string()));
352    }
353
354    #[test]
355    fn deduplicates_adjacent_segments() {
356        let value = Value::Array(vec![
357            Value::String("repeat".to_string()),
358            Value::String("repeat".to_string()),
359            Value::String("unique".to_string()),
360        ]);
361        let extracted = extract_reasoning_trace(&value);
362        assert_eq!(extracted, Some("repeat\nunique".to_string()));
363    }
364
365    #[test]
366    fn extracts_reasoning_from_think_markup() {
367        let source = "<think>first step</think>\n<answer>final output</answer>";
368        let (segments, cleaned) = split_reasoning_from_text(source);
369        assert_eq!(segments, vec![ReasoningSegment::new("first step", Some("think".to_string()))]);
370        assert_eq!(cleaned, Some("\nfinal output".to_string()));
371    }
372
373    #[test]
374    fn handles_nested_reasoning_markup() {
375        let source = "<think><analysis>deep dive</analysis> summary</think>";
376        let (segments, cleaned) = split_reasoning_from_text(source);
377        assert_eq!(
378            segments,
379            vec![
380                ReasoningSegment::new("deep dive", Some("analysis".to_string())),
381                ReasoningSegment::new("summary", Some("think".to_string()))
382            ]
383        );
384        assert!(cleaned.is_none());
385    }
386
387    #[test]
388    fn cleans_blank_lines_from_reasoning() {
389        let input = "line1\n\n\nline2\n\n\n\nline3";
390        let cleaned = clean_reasoning_text(input);
391        assert_eq!(cleaned, "line1\nline2\nline3");
392    }
393
394    #[test]
395    fn cleans_leading_and_trailing_blank_lines() {
396        let input = "\n\nline1\n\n\n\n";
397        let cleaned = clean_reasoning_text(input);
398        assert_eq!(cleaned, "line1");
399    }
400
401    #[test]
402    fn handles_empty_and_whitespace_only() {
403        assert_eq!(clean_reasoning_text(""), "");
404        assert_eq!(clean_reasoning_text("   "), "");
405        assert_eq!(clean_reasoning_text("\n\n\n"), "");
406    }
407
408    #[test]
409    fn removes_single_blank_lines() {
410        let input = "line1\n\nline2";
411        let cleaned = clean_reasoning_text(input);
412        assert_eq!(cleaned, "line1\nline2");
413    }
414
415    #[test]
416    fn handles_mixed_whitespace_lines() {
417        let input = "  line1  \n   \n  \n  line2  \n\t\n     \nline3";
418        let cleaned = clean_reasoning_text(input);
419        assert_eq!(cleaned, "  line1\n  line2\nline3");
420    }
421
422    #[test]
423    fn reasoning_buffer_preserves_leading_whitespace_spacing() {
424        let mut buffer = ReasoningBuffer::default();
425        let first = buffer.push("Hello");
426        assert_eq!(first.as_deref(), Some("Hello"));
427
428        let second = buffer.push(" world");
429        assert_eq!(second.as_deref(), Some(" world"));
430
431        let third = buffer.push("!");
432        assert_eq!(third.as_deref(), Some("!"));
433
434        let finalized = buffer.finalize();
435        assert_eq!(finalized.as_deref(), Some("Hello world!"));
436    }
437
438    #[test]
439    fn reasoning_buffer_keeps_subword_tokens_together() {
440        let mut buffer = ReasoningBuffer::default();
441        buffer.push("Andre");
442        buffer.push("j");
443        buffer.push(" Kar");
444        buffer.push("pathy");
445        buffer.push("'s");
446
447        let finalized = buffer.finalize();
448        assert_eq!(finalized.as_deref(), Some("Andrej Karpathy's"));
449    }
450
451    // ---- ReasoningBuffer dedup regression tests ----
452    // The `last_chunk_start` offset optimization changed the dedup
453    // mechanism from `Option<String>` comparison to byte-offset slicing.
454    // These tests lock down the exact semantics: consecutive identical
455    // chunks are suppressed, non-consecutive duplicates are kept, empty
456    // chunks never reset the dedup state, and Clone is safe.
457
458    #[test]
459    fn reasoning_buffer_suppresses_consecutive_duplicate_chunks() {
460        let mut buffer = ReasoningBuffer::default();
461        assert_eq!(buffer.push("hello").as_deref(), Some("hello"));
462        // Exact duplicate — must be suppressed.
463        assert_eq!(buffer.push("hello"), None);
464        // Different chunk after duplicate — must be accepted.
465        assert_eq!(buffer.push(" world").as_deref(), Some(" world"));
466        // Finalized text must not contain the duplicate.
467        assert_eq!(buffer.finalize().as_deref(), Some("hello world"));
468    }
469
470    #[test]
471    fn reasoning_buffer_keeps_non_consecutive_duplicate_chunks() {
472        let mut buffer = ReasoningBuffer::default();
473        assert_eq!(buffer.push("A").as_deref(), Some("A"));
474        assert_eq!(buffer.push("B").as_deref(), Some("B"));
475        // "A" again, but not consecutive — must be kept.
476        assert_eq!(buffer.push("A").as_deref(), Some("A"));
477        assert_eq!(buffer.finalize().as_deref(), Some("ABA"));
478    }
479
480    #[test]
481    fn reasoning_buffer_empty_chunks_do_not_reset_dedup_state() {
482        let mut buffer = ReasoningBuffer::default();
483        assert_eq!(buffer.push("chunk").as_deref(), Some("chunk"));
484        // Empty chunk — no-op, must not reset last-chunk tracking.
485        assert_eq!(buffer.push(""), None);
486        // Same chunk after empty — must still be suppressed.
487        assert_eq!(buffer.push("chunk"), None);
488        assert_eq!(buffer.finalize().as_deref(), Some("chunk"));
489    }
490
491    #[test]
492    fn reasoning_buffer_handles_unicode_chunks() {
493        let mut buffer = ReasoningBuffer::default();
494        let unicode = "思考过程";
495        assert_eq!(buffer.push(unicode).as_deref(), Some(unicode));
496        // Unicode duplicate — must be suppressed (byte offset is valid).
497        assert_eq!(buffer.push(unicode), None);
498        assert_eq!(buffer.push(" done").as_deref(), Some(" done"));
499        assert_eq!(buffer.finalize().as_deref(), Some("思考过程 done"));
500    }
501
502    #[test]
503    fn reasoning_buffer_clone_is_safe_then_push() {
504        let mut buffer = ReasoningBuffer::default();
505        buffer.push("first");
506        let mut clone = buffer.clone();
507        // Pushing to the clone must not affect the original.
508        assert_eq!(clone.push("second").as_deref(), Some("second"));
509        // Original still has "first" as last chunk — duplicate suppressed.
510        assert_eq!(buffer.push("first"), None);
511        assert_eq!(buffer.finalize().as_deref(), Some("first"));
512        assert_eq!(clone.finalize().as_deref(), Some("firstsecond"));
513    }
514}