fix(agents): drop malformed reasoning before orphan close tags · openclaw/openclaw@8e14f5c
steipete
·
2026-04-27
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
21 | 21 | ### Fixes |
22 | 22 | |
23 | 23 | - 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. |
| 24 | +- 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. |
24 | 25 | - 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. |
25 | 26 | - 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. |
26 | 27 | - 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. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -630,6 +630,18 @@ describe("subscribeEmbeddedPiSession", () => {
|
630 | 630 | expect(payloads[1]?.delta).toBe(" world"); |
631 | 631 | }); |
632 | 632 | |
| 633 | +it("drops malformed streamed reasoning before orphan close tags when final text follows", () => { |
| 634 | +const { emit, onAgentEvent } = createAgentEventHarness(); |
| 635 | + |
| 636 | +emit({ type: "message_start", message: { role: "assistant" } }); |
| 637 | +emitAssistantTextDelta(emit, "private chain of thought </think> Visible answer"); |
| 638 | + |
| 639 | +const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); |
| 640 | +expect(payloads).toHaveLength(1); |
| 641 | +expect(payloads[0]?.text).toBe("Visible answer"); |
| 642 | +expect(payloads[0]?.delta).toBe("Visible answer"); |
| 643 | +}); |
| 644 | + |
633 | 645 | it("emits agent events on message_end for non-streaming assistant text", () => { |
634 | 646 | const { session, emit } = createStubSessionHarness(); |
635 | 647 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -9,6 +9,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
9 | 9 | import type { InlineCodeState } from "../markdown/code-spans.js"; |
10 | 10 | import { buildCodeSpanIndex, createInlineCodeState } from "../markdown/code-spans.js"; |
11 | 11 | import { normalizeOptionalString } from "../shared/string-coerce.js"; |
| 12 | +import { hasOrphanReasoningCloseBoundary } from "../shared/text/reasoning-tags.js"; |
12 | 13 | import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; |
13 | 14 | import { |
14 | 15 | isMessagingToolDuplicateNormalized, |
@@ -531,10 +532,22 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
531 | 532 | if (codeSpans.isInside(idx)) { |
532 | 533 | continue; |
533 | 534 | } |
| 535 | +const isClose = match[1] === "/"; |
534 | 536 | if (!inThinking) { |
| 537 | +if (isClose) { |
| 538 | +const afterIndex = idx + match[0].length; |
| 539 | +const before = text.slice(lastIndex, idx); |
| 540 | +const after = text.slice(afterIndex); |
| 541 | +if (hasOrphanReasoningCloseBoundary({ before, after })) { |
| 542 | +processed = ""; |
| 543 | +} else { |
| 544 | +processed += before; |
| 545 | +} |
| 546 | +lastIndex = afterIndex; |
| 547 | +continue; |
| 548 | +} |
535 | 549 | processed += text.slice(lastIndex, idx); |
536 | 550 | } |
537 | | -const isClose = match[1] === "/"; |
538 | 551 | inThinking = !isClose; |
539 | 552 | lastIndex = idx + match[0].length; |
540 | 553 | } |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -528,6 +528,12 @@ describe("sanitizeAssistantVisibleText", () => {
|
528 | 528 | |
529 | 529 | expect(sanitizeAssistantVisibleText(input)).toBe("Visible answer"); |
530 | 530 | }); |
| 531 | + |
| 532 | +it("drops malformed reasoning before orphan close tags when final text follows", () => { |
| 533 | +expect(sanitizeAssistantVisibleText("private chain of thought </think> Visible answer")).toBe( |
| 534 | +"Visible answer", |
| 535 | +); |
| 536 | +}); |
531 | 537 | }); |
532 | 538 | |
533 | 539 | describe("sanitizeAssistantVisibleTextWithProfile", () => { |
@@ -537,6 +543,15 @@ describe("sanitizeAssistantVisibleTextWithProfile", () => {
|
537 | 543 | expect(sanitizeAssistantVisibleTextWithProfile(input, "history")).toBe("Hi there"); |
538 | 544 | }); |
539 | 545 | |
| 546 | +it("uses the history profile to drop malformed reasoning before orphan close tags", () => { |
| 547 | +expect( |
| 548 | +sanitizeAssistantVisibleTextWithProfile( |
| 549 | +"private chain of thought </think> Visible answer", |
| 550 | +"history", |
| 551 | +), |
| 552 | +).toBe(" Visible answer"); |
| 553 | +}); |
| 554 | + |
540 | 555 | it("uses the internal-scaffolding profile to preserve downgraded tool text behavior", () => { |
541 | 556 | const input = [ |
542 | 557 | "[Tool Call: read (ID: toolu_1)]", |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -120,6 +120,14 @@ describe("stripReasoningTagsFromText", () => {
|
120 | 120 | input: "You can start with <think and then close with </think>", |
121 | 121 | expected: "You can start with <think and then close with", |
122 | 122 | }, |
| 123 | +{ |
| 124 | +input: "Internal reasoning </think> final answer", |
| 125 | +expected: "final answer", |
| 126 | +}, |
| 127 | +{ |
| 128 | +input: "Use `<think>` to open and `</think>` to close. Final sentence.", |
| 129 | +expected: "Use `<think>` to open and `</think>` to close. Final sentence.", |
| 130 | +}, |
123 | 131 | { |
124 | 132 | input: "A < think >content< /think > B", |
125 | 133 | expected: "A B", |
@@ -168,7 +176,7 @@ describe("stripReasoningTagsFromText", () => {
|
168 | 176 | it.each([ |
169 | 177 | { |
170 | 178 | input: "<think>outer <think>inner</think> still outer</think>visible", |
171 | | -expected: "still outervisible", |
| 179 | +expected: "visible", |
172 | 180 | }, |
173 | 181 | { |
174 | 182 | input: "A<final>1</final>B<final>2</final>C", |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -17,6 +17,13 @@ function applyTrim(value: string, mode: ReasoningTagTrim): string {
|
17 | 17 | return value.trim(); |
18 | 18 | } |
19 | 19 | |
| 20 | +export function hasOrphanReasoningCloseBoundary(params: { |
| 21 | +before: string; |
| 22 | +after: string; |
| 23 | +}): boolean { |
| 24 | +return params.before.trim().length > 0 && params.after.trim().length > 0; |
| 25 | +} |
| 26 | + |
20 | 27 | export function stripReasoningTagsFromText( |
21 | 28 | text: string, |
22 | 29 | options?: { |
@@ -63,7 +70,7 @@ export function stripReasoningTagsFromText(
|
63 | 70 | THINKING_TAG_RE.lastIndex = 0; |
64 | 71 | let result = ""; |
65 | 72 | let lastIndex = 0; |
66 | | -let inThinking = false; |
| 73 | +let thinkingDepth = 0; |
67 | 74 | |
68 | 75 | for (const match of cleaned.matchAll(THINKING_TAG_RE)) { |
69 | 76 | const idx = match.index ?? 0; |
@@ -73,19 +80,31 @@ export function stripReasoningTagsFromText(
|
73 | 80 | continue; |
74 | 81 | } |
75 | 82 | |
76 | | -if (!inThinking) { |
77 | | -result += cleaned.slice(lastIndex, idx); |
78 | | -if (!isClose) { |
79 | | -inThinking = true; |
| 83 | +if (thinkingDepth === 0) { |
| 84 | +if (isClose) { |
| 85 | +const afterIndex = idx + match[0].length; |
| 86 | +const before = cleaned.slice(lastIndex, idx); |
| 87 | +const after = cleaned.slice(afterIndex); |
| 88 | +if (hasOrphanReasoningCloseBoundary({ before, after })) { |
| 89 | +result = ""; |
| 90 | +} else { |
| 91 | +result += before; |
| 92 | +} |
| 93 | +lastIndex = afterIndex; |
| 94 | +continue; |
80 | 95 | } |
| 96 | +result += cleaned.slice(lastIndex, idx); |
| 97 | +thinkingDepth = 1; |
81 | 98 | } else if (isClose) { |
82 | | -inThinking = false; |
| 99 | +thinkingDepth -= 1; |
| 100 | +} else { |
| 101 | +thinkingDepth += 1; |
83 | 102 | } |
84 | 103 | |
85 | 104 | lastIndex = idx + match[0].length; |
86 | 105 | } |
87 | 106 | |
88 | | -if (!inThinking || mode === "preserve") { |
| 107 | +if (thinkingDepth === 0 || mode === "preserve") { |
89 | 108 | result += cleaned.slice(lastIndex); |
90 | 109 | } |
91 | 110 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -53,6 +53,12 @@ describe("stripThinkingTags", () => {
|
53 | 53 | expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n"); |
54 | 54 | }); |
55 | 55 | |
| 56 | +it("drops malformed reasoning before orphan close tags when final text follows", () => { |
| 57 | +expect(stripThinkingTags("private chain of thought </think> Visible answer")).toBe( |
| 58 | +"Visible answer", |
| 59 | +); |
| 60 | +}); |
| 61 | + |
56 | 62 | it("returns original text when no tags exist", () => { |
57 | 63 | expect(stripThinkingTags("Hello")).toBe("Hello"); |
58 | 64 | }); |
|
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。