feat: add consistent handling for all reasoning tag variants

- Updated presentAssistantMessage.ts to strip all reasoning tags (<think>, <thinking>, <reasoning>, <thought>)
- Created ReasoningXmlMatcher utility to handle multiple reasoning tag variants
- Updated all provider parsers to use ReasoningXmlMatcher
- Ensures all four tag variants render identically as collapsible grey reasoning blocks

Fixes #8785
This commit is contained in:
Roo Code 2025-10-23 03:08:28 +00:00
parent 8187a8e189
commit 620c3cdcb9
10 changed files with 276 additions and 36 deletions

View file

@ -6,7 +6,7 @@ import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { ApiStream } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index"
import { BaseProvider } from "./base-provider"
@ -187,9 +187,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
throw new Error(t("common:errors.cerebras.noResponseBody"))
}
// Initialize XmlMatcher to parse <think>...</think> tags
const matcher = new XmlMatcher(
"think",
// Initialize ReasoningXmlMatcher to parse reasoning tags
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
@ -228,7 +227,7 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
if (parsed.choices?.[0]?.delta?.content) {
const content = parsed.choices[0].delta.content
// Use XmlMatcher to parse <think>...</think> tags
// Use ReasoningXmlMatcher to parse reasoning tags
for (const chunk of matcher.update(content)) {
yield chunk
}

View file

@ -3,7 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import { convertToR1Format } from "../transform/r1-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@ -53,8 +53,7 @@ export class ChutesHandler extends BaseOpenAiCompatibleProvider<ChutesModelId> {
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
})
const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",

View file

@ -1,9 +1,14 @@
import { DEEP_SEEK_DEFAULT_TEMPERATURE, type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types"
import {
DEEP_SEEK_DEFAULT_TEMPERATURE,
type FeatherlessModelId,
featherlessDefaultModelId,
featherlessModels,
} from "@roo-code/types"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import { convertToR1Format } from "../transform/r1-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@ -53,8 +58,7 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider<Featherless
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
})
const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",

View file

@ -6,7 +6,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU
import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@ -100,8 +100,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
throw handleOpenAIError(error, this.providerName)
}
const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",

View file

@ -5,7 +5,7 @@ import { ApiStream } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import type { ApiHandlerOptions } from "../../shared/api"
import { getOllamaModels } from "./fetchers/ollama"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
interface OllamaChatOptions {
@ -179,8 +179,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
...convertToOllamaMessages(messages),
]
const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",

View file

@ -5,7 +5,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERAT
import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
@ -68,8 +68,7 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",

View file

@ -12,7 +12,7 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
@ -179,8 +179,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
throw handleOpenAIError(error, this.providerName)
}
const matcher = new XmlMatcher(
"think",
const matcher = new ReasoningXmlMatcher(
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",

View file

@ -93,21 +93,24 @@ export async function presentAssistantMessage(cline: Task) {
if (content) {
// Have to do this for partial and complete since sending
// content in thinking tags to markdown renderer will
// content in reasoning tags to markdown renderer will
// automatically be removed.
// Remove end substrings of <thinking or </thinking (below xml
// parsing is only for opening tags).
// Tthis is done with the xml parsing below now, but keeping
// here for reference.
// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
//
// Remove all instances of <thinking> (with optional line break
// after) and </thinking> (with optional line break before).
// Remove all instances of reasoning tags: <think>, <thinking>, <reasoning>, <thought>
// (with optional line break after opening tags) and their closing tags
// (with optional line break before closing tags).
// - Needs to be separate since we dont want to remove the line
// break before the first tag.
// - Needs to happen before the xml parsing below.
content = content.replace(/<thinking>\s?/g, "")
content = content.replace(/\s?<\/thinking>/g, "")
const reasoningTags = ["think", "thinking", "reasoning", "thought"]
reasoningTags.forEach((tag) => {
// Remove opening tags with optional line break after
const openingRegex = new RegExp(`<${tag}>\\s?`, "g")
content = content.replace(openingRegex, "")
// Remove closing tags with optional line break before
const closingRegex = new RegExp(`\\s?<\\/${tag}>`, "g")
content = content.replace(closingRegex, "")
})
// Remove partial XML tag at the very end of the content (for
// tool use and thinking tags), Prevents scrollview from
@ -136,14 +139,20 @@ export async function presentAssistantMessage(cline: Task) {
// (letters and underscores only).
const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)
// Check if it's a partial reasoning tag
const reasoningTags = ["think", "thinking", "reasoning", "thought"]
const isPartialReasoningTag = reasoningTags.some(
(tag) => tag.startsWith(tagContent) || tagContent.startsWith(tag),
)
// Preemptively remove < or </ to keep from these
// artifacts showing up in chat (also handles closing
// thinking tags).
// reasoning tags).
const isOpeningOrClosing = possibleTag === "<" || possibleTag === "</"
// If the tag is incomplete and at the end, remove it
// from the content.
if (isOpeningOrClosing || isLikelyTagName) {
if (isOpeningOrClosing || isLikelyTagName || isPartialReasoningTag) {
content = content.slice(0, lastOpenBracketIndex).trim()
}
}

View file

@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest"
import { ReasoningXmlMatcher } from "../reasoning-xml-matcher"
describe("ReasoningXmlMatcher", () => {
it("should match <think> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <think>This is reasoning content</think> more text"
const results = matcher.final(input)
expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<think>This is reasoning content</think>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})
it("should match <thinking> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <thinking>This is reasoning content</thinking> more text"
const results = matcher.final(input)
expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<thinking>This is reasoning content</thinking>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})
it("should match <reasoning> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <reasoning>This is reasoning content</reasoning> more text"
const results = matcher.final(input)
expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<reasoning>This is reasoning content</reasoning>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})
it("should match <thought> tags", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Some text <thought>This is reasoning content</thought> more text"
const results = matcher.final(input)
expect(results).toHaveLength(3)
expect(results[0]).toEqual({ matched: false, data: "Some text " })
expect(results[1]).toEqual({ matched: true, data: "<thought>This is reasoning content</thought>" })
expect(results[2]).toEqual({ matched: false, data: " more text" })
})
it("should handle streaming updates for all tag variants", () => {
const testCases = [
{ tag: "think", content: "Thinking about the problem" },
{ tag: "thinking", content: "Processing the request" },
{ tag: "reasoning", content: "Analyzing the situation" },
{ tag: "thought", content: "Considering options" },
]
testCases.forEach(({ tag, content }) => {
const matcher = new ReasoningXmlMatcher()
// Simulate streaming
const chunks = [
"Initial text ",
`<${tag}>`,
content.slice(0, 10),
content.slice(10),
`</${tag}>`,
" final text",
]
let allResults: any[] = []
chunks.forEach((chunk) => {
const results = matcher.update(chunk)
allResults.push(...results)
})
// Get final results
const finalResults = matcher.final()
allResults.push(...finalResults)
// Verify we got the expected matched content
const matchedResults = allResults.filter((r) => r.matched)
const unmatchedResults = allResults.filter((r) => !r.matched)
expect(matchedResults.length).toBeGreaterThan(0)
const fullMatchedContent = matchedResults.map((r) => r.data).join("")
expect(fullMatchedContent).toContain(content)
const fullUnmatchedContent = unmatchedResults.map((r) => r.data).join("")
expect(fullUnmatchedContent).toContain("Initial text")
expect(fullUnmatchedContent).toContain("final text")
})
})
it("should handle nested tags correctly", () => {
const matcher = new ReasoningXmlMatcher()
const input = "<think>Outer <think>Inner</think> content</think>"
const results = matcher.final(input)
// Should match the entire nested structure
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
matched: true,
data: "<think>Outer <think>Inner</think> content</think>",
})
})
it("should handle multiple different reasoning tags in sequence", () => {
const matcher = new ReasoningXmlMatcher()
const input = "Text <think>Think content</think> middle <thinking>Thinking content</thinking> end"
const results = matcher.final(input)
// Should match only the first tag type encountered
expect(results.filter((r) => r.matched).length).toBeGreaterThan(0)
expect(results.some((r) => r.data.includes("Think content"))).toBe(true)
})
it("should apply custom transform function", () => {
const transform = (chunk: { matched: boolean; data: string }) => ({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
})
const matcher = new ReasoningXmlMatcher(transform)
const input = "Normal text <think>Reasoning here</think> more text"
const results = matcher.final(input)
expect(results[0]).toEqual({ type: "text", text: "Normal text " })
expect(results[1]).toEqual({ type: "reasoning", text: "<think>Reasoning here</think>" })
expect(results[2]).toEqual({ type: "text", text: " more text" })
})
})

View file

@ -0,0 +1,102 @@
import { XmlMatcher, XmlMatcherResult } from "./xml-matcher"
/**
* A wrapper around XmlMatcher that can match multiple tag names for reasoning blocks.
* This handles <think>, <thinking>, <reasoning>, and <thought> tags uniformly.
*
* It works by using a single XmlMatcher configured to match the shortest tag name
* and then validates if the full tag is one of the reasoning variants.
*/
export class ReasoningXmlMatcher<Result = XmlMatcherResult> {
private reasoningTags = ["think", "thinking", "reasoning", "thought"]
private results: Result[] = []
private buffer = ""
private isProcessing = false
constructor(
private readonly transform?: (chunks: XmlMatcherResult) => Result,
private readonly position = 0,
) {}
private processWithTag(input: string, tagName: string): XmlMatcherResult[] {
const matcher = new XmlMatcher(tagName, undefined, this.position)
return matcher.final(input)
}
private extractMatchedResults(input: string): Result[] {
// Try each tag type to find matches
for (const tag of this.reasoningTags) {
// Check if the input contains this tag
if (input.includes(`<${tag}>`) || input.includes(`</${tag}>`)) {
const results = this.processWithTag(input, tag)
if (results.length > 0) {
// Transform results if needed
if (this.transform) {
return results.map(this.transform)
}
return results as Result[]
}
}
}
// No reasoning tags found, return the input as unmatched
const unmatchedResult: XmlMatcherResult = {
matched: false,
data: input,
}
if (this.transform) {
return [this.transform(unmatchedResult)]
}
return [unmatchedResult as Result]
}
update(chunk: string): Result[] {
this.buffer += chunk
this.results = []
// Don't process until we have a complete tag or enough content
// This prevents partial processing issues
if (!this.buffer.includes(">")) {
return this.results
}
// Check if we have any complete reasoning blocks
let hasCompleteBlock = false
for (const tag of this.reasoningTags) {
const openTag = `<${tag}>`
const closeTag = `</${tag}>`
if (this.buffer.includes(openTag) && this.buffer.includes(closeTag)) {
const openIndex = this.buffer.indexOf(openTag)
const closeIndex = this.buffer.indexOf(closeTag, openIndex)
if (closeIndex > openIndex) {
hasCompleteBlock = true
break
}
}
}
// If we have a complete block, process it
if (hasCompleteBlock) {
const results = this.extractMatchedResults(this.buffer)
this.buffer = ""
this.results = results
}
return this.results
}
final(chunk?: string): Result[] {
if (chunk) {
this.buffer += chunk
}
if (this.buffer.length === 0) {
return []
}
const results = this.extractMatchedResults(this.buffer)
this.buffer = ""
return results
}
}