mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(reasoning-tags): prevent internal reflections in replies (#123196)
Treat <internal> blocks as private reasoning in the shared parser and remove Telegram raw-reasoning fallbacks. This keeps model reflections out of user-visible replies while preserving surrounding answer text. Closes #122623 Co-authored-by: Ayaan Zaidi <hi@obviy.us> Co-authored-by: WangYan <wang.yan29@xydigit.com>
This commit is contained in:
@@ -326,7 +326,8 @@ export function splitTextIntoLaneSegments(
|
||||
...(update.isReasoningSnapshot ? { isReasoningSnapshot: true } : {}),
|
||||
},
|
||||
})),
|
||||
suppressedReasoningOnly: Boolean(split.reasoningText) && suppressReasoning && !split.answerText,
|
||||
suppressedReasoningOnly:
|
||||
isReasoning === true && !split.answerText && (suppressReasoning || !split.reasoningText),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,24 @@ describeTelegramDispatch("dispatchTelegramMessage reasoning-room-events", () =>
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses internal reflection when reasoning streams", async () => {
|
||||
const { reasoningDraftStream } = setupDraftStreams({
|
||||
answerMessageId: 2001,
|
||||
reasoningMessageId: 3001,
|
||||
});
|
||||
mockTurn(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver(
|
||||
{ text: "<internal>private reflection</internal>", isReasoning: true },
|
||||
{ kind: "final" },
|
||||
);
|
||||
});
|
||||
|
||||
await dispatchWithContext({ context: createReasoningStreamContext() });
|
||||
|
||||
expect(reasoningDraftStream.update).not.toHaveBeenCalled();
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes typed reasoning-only finals to durable delivery when reasoning is persistent", async () => {
|
||||
loadSessionStore.mockReturnValue({
|
||||
s1: { reasoningLevel: "on" },
|
||||
|
||||
@@ -23,6 +23,10 @@ describe("splitTelegramReasoningText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses internal reflection from explicitly typed reasoning", () => {
|
||||
expect(splitTelegramReasoningText("<internal>private reflection</internal>", true)).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores literal think tags inside inline code", () => {
|
||||
const text = "Use `<think>example</think>` literally.";
|
||||
expect(splitTelegramReasoningText(text)).toEqual({
|
||||
@@ -39,6 +43,7 @@ describe("splitTelegramReasoningText", () => {
|
||||
|
||||
it("does not emit partial reasoning tag prefixes", () => {
|
||||
expect(splitTelegramReasoningText(" <thi", true)).toStrictEqual({});
|
||||
expect(splitTelegramReasoningText(" <int", true)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("keeps visible Thinking-prefixed answers in the answer lane", () => {
|
||||
|
||||
@@ -29,11 +29,13 @@ const REASONING_TAG_PREFIXES = [
|
||||
"<think",
|
||||
"<thinking",
|
||||
"<thought",
|
||||
"<internal",
|
||||
"<antthinking",
|
||||
"<mm:think",
|
||||
"</think",
|
||||
"</thinking",
|
||||
"</thought",
|
||||
"</internal",
|
||||
"</antthinking",
|
||||
"</mm:think",
|
||||
];
|
||||
@@ -116,11 +118,13 @@ export function splitTelegramReasoningText(
|
||||
|
||||
const taggedReasoning = extractThinkingFromTaggedStreamOutsideCode(text);
|
||||
const strippedAnswer = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
|
||||
const reasoningText = taggedReasoning || strippedAnswer;
|
||||
if (!reasoningText) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
reasoningText: markReasoningMessage(
|
||||
formatReasoningMessage(taggedReasoning || strippedAnswer || text),
|
||||
),
|
||||
reasoningText: markReasoningMessage(formatReasoningMessage(reasoningText)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -736,4 +736,18 @@ describe("createReasoningTagTextPartitioner", () => {
|
||||
expect(partitioner.pushVisible("<think>outer<think>inner</think>")).toEqual([]);
|
||||
expect(partitioner.flush()).toEqual([{ kind: "thinking", text: "outerinner" }]);
|
||||
});
|
||||
|
||||
it("never emits nested unclosed internal reflection on flush", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("<thinking>outer<internal>private reflection")).toEqual([]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("never emits closed internal reflection", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("<internal>private reflection</internal>")).toEqual([]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export const REASONING_TAG_NAMES = [
|
||||
"thinking",
|
||||
"thought",
|
||||
"reasoning",
|
||||
"internal",
|
||||
"antthinking",
|
||||
"antml:think",
|
||||
"antml:thinking",
|
||||
@@ -32,6 +33,7 @@ type ReasoningTagMatch = {
|
||||
text: string;
|
||||
isClose: boolean;
|
||||
isSelfClosing: boolean;
|
||||
isPrivate: boolean;
|
||||
};
|
||||
|
||||
type ReasoningTagScan = {
|
||||
@@ -127,6 +129,7 @@ export function parseReasoningTagAt(
|
||||
text: text.slice(start, end),
|
||||
isClose,
|
||||
isSelfClosing: !isClose && lastSignificant === "/",
|
||||
isPrivate: partialName === "internal",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -252,6 +255,7 @@ export type ReductionState = {
|
||||
visibleEver: boolean;
|
||||
pending?: {
|
||||
content: string;
|
||||
containsPrivate: boolean;
|
||||
openTag: string;
|
||||
protectedClose: boolean;
|
||||
visibleBefore: boolean;
|
||||
@@ -306,18 +310,20 @@ export function reduceReasoningText(
|
||||
index: scannedTag.index + start,
|
||||
isClose: scannedTag.isClose,
|
||||
isSelfClosing: scannedTag.isSelfClosing,
|
||||
isPrivate: scannedTag.isPrivate,
|
||||
text: scannedTag.text,
|
||||
};
|
||||
if (!isInsideCode(tag.index, codeSpans)) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
const hasCloseAfter: boolean[] = [];
|
||||
const mustParseRemainder: boolean[] = [];
|
||||
if (options.scope === "leading") {
|
||||
let seenClose = false;
|
||||
let mustParse = false;
|
||||
for (let index = tags.length - 1; index >= 0; index -= 1) {
|
||||
hasCloseAfter[index] = seenClose;
|
||||
seenClose ||= tags[index]?.isClose === true;
|
||||
mustParse ||= tags[index]?.isPrivate === true;
|
||||
mustParseRemainder[index] = mustParse;
|
||||
mustParse ||= tags[index]?.isClose === true;
|
||||
}
|
||||
}
|
||||
let cursor = start;
|
||||
@@ -346,7 +352,7 @@ export function reduceReasoningText(
|
||||
state.depth === 0 &&
|
||||
options.scope === "leading" &&
|
||||
state.visibleEver &&
|
||||
!hasCloseAfter[tagIndex]
|
||||
!mustParseRemainder[tagIndex]
|
||||
) {
|
||||
emit("text", text.slice(tag.index));
|
||||
cursor = text.length;
|
||||
@@ -355,10 +361,14 @@ export function reduceReasoningText(
|
||||
if (state.depth === 0) {
|
||||
state.pending = {
|
||||
content: "",
|
||||
containsPrivate: tag.isPrivate,
|
||||
openTag: tag.text,
|
||||
protectedClose: false,
|
||||
visibleBefore: state.visibleEver,
|
||||
};
|
||||
} else if (state.pending) {
|
||||
// A nested private block makes the enclosing reasoning non-emitting.
|
||||
state.pending.containsPrivate ||= tag.isPrivate;
|
||||
}
|
||||
state.depth += 1;
|
||||
cursor = tagEnd;
|
||||
@@ -368,7 +378,9 @@ export function reduceReasoningText(
|
||||
if (state.depth > 0) {
|
||||
state.depth -= 1;
|
||||
if (state.depth === 0 && state.pending) {
|
||||
emit("thinking", state.pending.content);
|
||||
if (!state.pending.containsPrivate) {
|
||||
emit("thinking", state.pending.content);
|
||||
}
|
||||
state.pending = undefined;
|
||||
} else if (state.pending) {
|
||||
state.pending.protectedClose = true;
|
||||
@@ -393,18 +405,20 @@ export function reduceReasoningText(
|
||||
append(text.slice(cursor));
|
||||
if (options.final && state.depth > 0 && state.pending) {
|
||||
const pending = state.pending;
|
||||
const recoverAsText =
|
||||
options.mode === "static-preserve" ||
|
||||
(options.mode === "static-strict" && !pending.visibleBefore && !pending.protectedClose) ||
|
||||
(options.mode === "visible" && !pending.protectedClose);
|
||||
if (recoverAsText) {
|
||||
const value =
|
||||
options.mode === "visible" && pending.visibleBefore
|
||||
? pending.openTag + pending.content
|
||||
: pending.content;
|
||||
emit("text", value);
|
||||
} else {
|
||||
emit("thinking", pending.content);
|
||||
if (!pending.containsPrivate) {
|
||||
const recoverAsText =
|
||||
options.mode === "static-preserve" ||
|
||||
(options.mode === "static-strict" && !pending.visibleBefore && !pending.protectedClose) ||
|
||||
(options.mode === "visible" && !pending.protectedClose);
|
||||
if (recoverAsText) {
|
||||
const value =
|
||||
options.mode === "visible" && pending.visibleBefore
|
||||
? pending.openTag + pending.content
|
||||
: pending.content;
|
||||
emit("text", value);
|
||||
} else {
|
||||
emit("thinking", pending.content);
|
||||
}
|
||||
}
|
||||
state.depth = 0;
|
||||
state.pending = undefined;
|
||||
|
||||
@@ -3806,6 +3806,13 @@ describe("message tool reasoning tag sanitization", () => {
|
||||
target: "telegram:123",
|
||||
channel: "telegram",
|
||||
},
|
||||
{
|
||||
field: "message",
|
||||
input: "<internal>private reflection</internal>Visible answer",
|
||||
expected: "Visible answer",
|
||||
target: "telegram:123",
|
||||
channel: "telegram",
|
||||
},
|
||||
{
|
||||
field: "message",
|
||||
input: "Thinking\n_internal plan_\n\nVisible answer",
|
||||
|
||||
@@ -38,6 +38,11 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
input: ["<thinking>", "secret", "</thinking>", "Visible"].join("\n"),
|
||||
expected: "Visible",
|
||||
},
|
||||
{
|
||||
name: "strips internal reflection tags",
|
||||
input: ["<internal>", "private reflection", "</internal>", "Visible"].join("\n"),
|
||||
expected: "Visible",
|
||||
},
|
||||
{
|
||||
name: "strips relevant-memories scaffolding blocks",
|
||||
input: [
|
||||
@@ -986,6 +991,12 @@ describe("sanitizeAssistantVisibleText", () => {
|
||||
"Before <think>literal tag text after",
|
||||
);
|
||||
});
|
||||
|
||||
it("never recovers unclosed internal reflection from final-answer prose", () => {
|
||||
expect(
|
||||
sanitizeAssistantFinalAnswerText("Visible prefix <thinking><internal>private reflection"),
|
||||
).toBe("Visible prefix");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAssistantVisibleTextWithProfile", () => {
|
||||
|
||||
@@ -81,6 +81,16 @@ describe("stripReasoningTagsFromText", () => {
|
||||
input: "<think>first</think>A<think>second</think>B",
|
||||
expected: "AB",
|
||||
},
|
||||
{
|
||||
name: "strips internal reflection blocks",
|
||||
input: "<internal>private reflection</internal>Visible answer.",
|
||||
expected: "Visible answer.",
|
||||
},
|
||||
{
|
||||
name: "never recovers nested unclosed internal reflection as visible text",
|
||||
input: "<thinking>outer<internal>private reflection",
|
||||
expected: "",
|
||||
},
|
||||
] as const)("$name", (testCase) => {
|
||||
expectStrippedCase(testCase);
|
||||
});
|
||||
@@ -96,6 +106,10 @@ describe("stripReasoningTagsFromText", () => {
|
||||
name: "preserves inline literal think tag documentation",
|
||||
input: "The `<think>` tag is used for reasoning. Don't forget the closing `</think>` tag.",
|
||||
},
|
||||
{
|
||||
name: "preserves literal internal tag documentation",
|
||||
input: "Use `<internal>private</internal>` literally.",
|
||||
},
|
||||
{
|
||||
name: "preserves xml fenced examples",
|
||||
input: "Example:\n```xml\n<think>\n <thought>nested</thought>\n</think>\n```\nDone!",
|
||||
@@ -370,6 +384,12 @@ describe("stripReasoningTagsFromText", () => {
|
||||
expected: "A B",
|
||||
opts: { mode: "preserve" as const },
|
||||
},
|
||||
{
|
||||
name: "does not recover internal reflection in preserve mode",
|
||||
input: "<internal>private reflection",
|
||||
expected: "",
|
||||
opts: { mode: "preserve" as const },
|
||||
},
|
||||
] as const)("$name", (testCase) => {
|
||||
expectStrippedCase(testCase);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user