mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
8187a8e189
commit
620c3cdcb9
10 changed files with 276 additions and 36 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
131
src/utils/__tests__/reasoning-xml-matcher.spec.ts
Normal file
131
src/utils/__tests__/reasoning-xml-matcher.spec.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
102
src/utils/reasoning-xml-matcher.ts
Normal file
102
src/utils/reasoning-xml-matcher.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue