fix(agents): drop malformed reasoning before orphan close tags

This commit is contained in:
Peter Steinberger
2026-04-27 12:06:09 +01:00
parent b081b195a3
commit 8e14f5c749
7 changed files with 83 additions and 9 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading `vcontrol-ui` connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy.
- Agents/reasoning: treat orphan closing reasoning tags with following answer text as a privacy boundary across delivery, history, streaming, and Control UI sanitizers so malformed local-model output cannot leak chain-of-thought text. Fixes #67092. Thanks @AnildoSilva.
- Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so `memory index`, `memory status --index`, and `memory search` no longer start long-lived file watchers that can hit macOS `EMFILE` limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue.
- Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010.
- WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset <note>` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg.

View File

@@ -630,6 +630,18 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payloads[1]?.delta).toBe(" world");
});
it("drops malformed streamed reasoning before orphan close tags when final text follows", () => {
const { emit, onAgentEvent } = createAgentEventHarness();
emit({ type: "message_start", message: { role: "assistant" } });
emitAssistantTextDelta(emit, "private chain of thought </think> Visible answer");
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("Visible answer");
expect(payloads[0]?.delta).toBe("Visible answer");
});
it("emits agent events on message_end for non-streaming assistant text", () => {
const { session, emit } = createStubSessionHarness();

View File

@@ -9,6 +9,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import type { InlineCodeState } from "../markdown/code-spans.js";
import { buildCodeSpanIndex, createInlineCodeState } from "../markdown/code-spans.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { hasOrphanReasoningCloseBoundary } from "../shared/text/reasoning-tags.js";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
import {
isMessagingToolDuplicateNormalized,
@@ -531,10 +532,22 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
if (codeSpans.isInside(idx)) {
continue;
}
const isClose = match[1] === "/";
if (!inThinking) {
if (isClose) {
const afterIndex = idx + match[0].length;
const before = text.slice(lastIndex, idx);
const after = text.slice(afterIndex);
if (hasOrphanReasoningCloseBoundary({ before, after })) {
processed = "";
} else {
processed += before;
}
lastIndex = afterIndex;
continue;
}
processed += text.slice(lastIndex, idx);
}
const isClose = match[1] === "/";
inThinking = !isClose;
lastIndex = idx + match[0].length;
}

View File

@@ -528,6 +528,12 @@ describe("sanitizeAssistantVisibleText", () => {
expect(sanitizeAssistantVisibleText(input)).toBe("Visible answer");
});
it("drops malformed reasoning before orphan close tags when final text follows", () => {
expect(sanitizeAssistantVisibleText("private chain of thought </think> Visible answer")).toBe(
"Visible answer",
);
});
});
describe("sanitizeAssistantVisibleTextWithProfile", () => {
@@ -537,6 +543,15 @@ describe("sanitizeAssistantVisibleTextWithProfile", () => {
expect(sanitizeAssistantVisibleTextWithProfile(input, "history")).toBe("Hi there");
});
it("uses the history profile to drop malformed reasoning before orphan close tags", () => {
expect(
sanitizeAssistantVisibleTextWithProfile(
"private chain of thought </think> Visible answer",
"history",
),
).toBe(" Visible answer");
});
it("uses the internal-scaffolding profile to preserve downgraded tool text behavior", () => {
const input = [
"[Tool Call: read (ID: toolu_1)]",

View File

@@ -120,6 +120,14 @@ describe("stripReasoningTagsFromText", () => {
input: "You can start with <think and then close with </think>",
expected: "You can start with <think and then close with",
},
{
input: "Internal reasoning </think> final answer",
expected: "final answer",
},
{
input: "Use `<think>` to open and `</think>` to close. Final sentence.",
expected: "Use `<think>` to open and `</think>` to close. Final sentence.",
},
{
input: "A < think >content< /think > B",
expected: "A B",
@@ -168,7 +176,7 @@ describe("stripReasoningTagsFromText", () => {
it.each([
{
input: "<think>outer <think>inner</think> still outer</think>visible",
expected: "still outervisible",
expected: "visible",
},
{
input: "A<final>1</final>B<final>2</final>C",

View File

@@ -17,6 +17,13 @@ function applyTrim(value: string, mode: ReasoningTagTrim): string {
return value.trim();
}
export function hasOrphanReasoningCloseBoundary(params: {
before: string;
after: string;
}): boolean {
return params.before.trim().length > 0 && params.after.trim().length > 0;
}
export function stripReasoningTagsFromText(
text: string,
options?: {
@@ -63,7 +70,7 @@ export function stripReasoningTagsFromText(
THINKING_TAG_RE.lastIndex = 0;
let result = "";
let lastIndex = 0;
let inThinking = false;
let thinkingDepth = 0;
for (const match of cleaned.matchAll(THINKING_TAG_RE)) {
const idx = match.index ?? 0;
@@ -73,19 +80,31 @@ export function stripReasoningTagsFromText(
continue;
}
if (!inThinking) {
result += cleaned.slice(lastIndex, idx);
if (!isClose) {
inThinking = true;
if (thinkingDepth === 0) {
if (isClose) {
const afterIndex = idx + match[0].length;
const before = cleaned.slice(lastIndex, idx);
const after = cleaned.slice(afterIndex);
if (hasOrphanReasoningCloseBoundary({ before, after })) {
result = "";
} else {
result += before;
}
lastIndex = afterIndex;
continue;
}
result += cleaned.slice(lastIndex, idx);
thinkingDepth = 1;
} else if (isClose) {
inThinking = false;
thinkingDepth -= 1;
} else {
thinkingDepth += 1;
}
lastIndex = idx + match[0].length;
}
if (!inThinking || mode === "preserve") {
if (thinkingDepth === 0 || mode === "preserve") {
result += cleaned.slice(lastIndex);
}

View File

@@ -53,6 +53,12 @@ describe("stripThinkingTags", () => {
expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n");
});
it("drops malformed reasoning before orphan close tags when final text follows", () => {
expect(stripThinkingTags("private chain of thought </think> Visible answer")).toBe(
"Visible answer",
);
});
it("returns original text when no tags exist", () => {
expect(stripThinkingTags("Hello")).toBe("Hello");
});