1use serde_json::Value;
2
3#[derive(Default, Clone)]
4pub struct ReasoningBuffer {
5 text: String,
6 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 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 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 #[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 assert_eq!(buffer.push("hello"), None);
464 assert_eq!(buffer.push(" world").as_deref(), Some(" world"));
466 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 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 assert_eq!(buffer.push(""), None);
486 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 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 assert_eq!(clone.push("second").as_deref(), Some("second"));
509 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}