fix(agent): strip thinking tags from text content

This commit is contained in:
Erik
2026-01-14 14:55:48 +01:00
committed by Peter Steinberger
parent 8146c43aa3
commit 3b7d103758
2 changed files with 121 additions and 1 deletions

View File

@@ -358,4 +358,84 @@ File contents here`,
const result = extractAssistantText(msg);
expect(result).toBe("Here's what I found:\nDone checking.");
});
it("strips thinking tags from text content", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<think>El usuario quiere retomar una tarea...</think>Aquí está tu respuesta.",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Aquí está tu respuesta.");
});
it("strips thinking tags without closing tag", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<think>Pensando sobre el problema...",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("");
});
it("strips thinking tags with various formats", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "Before<thinking>internal reasoning</thinking>After",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("BeforeAfter");
});
it("strips antthinking tags", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<antthinking>Some reasoning</antthinking>The actual answer.",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("The actual answer.");
});
it("handles nested or multiple thinking blocks", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "Start<think>first thought</think>Middle<think>second thought</think>End",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("StartMiddleEnd");
});
});

View File

@@ -47,6 +47,44 @@ function stripDowngradedToolCallText(text: string): string {
return cleaned.trim();
}
/**
* Strip thinking tags and their content from text.
* This is a safety net for cases where the model outputs <think> tags
* that slip through other filtering mechanisms.
*/
function stripThinkingTagsFromText(text: string): string {
if (!text) return text;
// Quick check to avoid regex overhead when no tags present.
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
const tagRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of text.matchAll(tagRe)) {
const idx = match.index ?? 0;
const isClose = match[1] === "/";
if (!inThinking && !isClose) {
// Opening tag - save text before it.
result += text.slice(lastIndex, idx);
inThinking = true;
} else if (inThinking && isClose) {
// Closing tag - skip content inside.
inThinking = false;
}
lastIndex = idx + match[0].length;
}
// Append remaining text if we're not inside thinking.
if (!inThinking) {
result += text.slice(lastIndex);
}
return result.trim();
}
export function extractAssistantText(msg: AssistantMessage): string {
const isTextBlock = (block: unknown): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object") return false;
@@ -58,7 +96,9 @@ export function extractAssistantText(msg: AssistantMessage): string {
? msg.content
.filter(isTextBlock)
.map((c) =>
stripDowngradedToolCallText(stripMinimaxToolCallXml(c.text)).trim(),
stripThinkingTagsFromText(
stripDowngradedToolCallText(stripMinimaxToolCallXml(c.text)),
).trim(),
)
.filter(Boolean)
: [];