mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:40:44 +00:00
fix(agents): drop malformed reasoning before orphan close tags
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)]",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user