From 8e14f5c74925d38323797970a976f5e5c33d01d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 12:06:09 +0100 Subject: [PATCH] fix(agents): drop malformed reasoning before orphan close tags --- CHANGELOG.md | 1 + ...session.subscribeembeddedpisession.test.ts | 12 +++++++ src/agents/pi-embedded-subscribe.ts | 15 ++++++++- .../text/assistant-visible-text.test.ts | 15 +++++++++ src/shared/text/reasoning-tags.test.ts | 10 +++++- src/shared/text/reasoning-tags.ts | 33 +++++++++++++++---- ui/src/ui/format.test.ts | 6 ++++ 7 files changed, 83 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ca15ab636..87abc6e9c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg. diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 0c86f5c8676..8db3e5353e6 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -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 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(); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 06fc722a47a..9f111f65082 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -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; } diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts index b27181710ce..2adc98f1f10 100644 --- a/src/shared/text/assistant-visible-text.test.ts +++ b/src/shared/text/assistant-visible-text.test.ts @@ -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 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 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)]", diff --git a/src/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts index e85ee56615c..a18b1d68616 100644 --- a/src/shared/text/reasoning-tags.test.ts +++ b/src/shared/text/reasoning-tags.test.ts @@ -120,6 +120,14 @@ describe("stripReasoningTagsFromText", () => { input: "You can start with ", expected: "You can start with final answer", + expected: "final answer", + }, + { + input: "Use `` to open and `` to close. Final sentence.", + expected: "Use `` to open and `` to close. Final sentence.", + }, { input: "A < think >content< /think > B", expected: "A B", @@ -168,7 +176,7 @@ describe("stripReasoningTagsFromText", () => { it.each([ { input: "outer inner still outervisible", - expected: "still outervisible", + expected: "visible", }, { input: "A1B2C", diff --git a/src/shared/text/reasoning-tags.ts b/src/shared/text/reasoning-tags.ts index 62b73d5fe4f..1fbd3fa46e9 100644 --- a/src/shared/text/reasoning-tags.ts +++ b/src/shared/text/reasoning-tags.ts @@ -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); } diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts index 3c8185d4dca..fc8fe7ec4a5 100644 --- a/ui/src/ui/format.test.ts +++ b/ui/src/ui/format.test.ts @@ -53,6 +53,12 @@ describe("stripThinkingTags", () => { expect(stripThinkingTags("Hello\n")).toBe("Hello\n"); }); + it("drops malformed reasoning before orphan close tags when final text follows", () => { + expect(stripThinkingTags("private chain of thought Visible answer")).toBe( + "Visible answer", + ); + }); + it("returns original text when no tags exist", () => { expect(stripThinkingTags("Hello")).toBe("Hello"); });