mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: CJK replies are silently dropped instead of recovered when the model skips message(action=send) (#115556)
* fix(auto-reply): count CJK sentence terminators in stranded private-final detection * fix(auto-reply): compare private-final substance thresholds with the CJK-aware estimator * fix(auto-reply): detect CJK sentence boundaries --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// Tests private message-tool final delivery and visibility suppression.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateStringChars } from "../../utils/cjk-chars.js";
|
||||
import { shouldWarnAboutPrivateMessageToolFinal } from "./private-message-tool-final.js";
|
||||
|
||||
const base = {
|
||||
@@ -64,6 +65,75 @@ describe("shouldWarnAboutPrivateMessageToolFinal", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Raw UTF-16 length under-counts CJK about 4x, so these all used to fall below
|
||||
// both thresholds and skip stranded recovery entirely (#115555).
|
||||
it.each([
|
||||
{
|
||||
label: "multi-sentence CJK report",
|
||||
finalText:
|
||||
"近 7 日營收較前期增加 5.09%,已連續兩週回升。最大風險是集中:前五大站台占正營收 86.5%,已超過 85% 觀察門檻。" +
|
||||
"建議先維持成長節奏並優先降低集中風險,不建議只看總額就全面加碼。",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: "single-sentence CJK paragraph (length alone is substantive)",
|
||||
finalText: `${"字".repeat(150)}。`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: "CJK using full-width period U+FF0E",
|
||||
finalText: `${"項".repeat(150)}.`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: "CJK using halfwidth ideographic period U+FF61",
|
||||
finalText: `${"項".repeat(150)}。`,
|
||||
expected: true,
|
||||
},
|
||||
{ label: "short CJK acknowledgement", finalText: "沒有需要補充的。已完成。", expected: false },
|
||||
{ label: "short CJK single clause", finalText: "已完成", expected: false },
|
||||
])("$label -> $expected", ({ finalText, expected }) => {
|
||||
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText })).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "ideographic full stop", terminator: "。" },
|
||||
{ label: "full-width exclamation mark", terminator: "!" },
|
||||
{ label: "full-width question mark", terminator: "?" },
|
||||
{ label: "full-width full stop", terminator: "." },
|
||||
{ label: "half-width ideographic full stop", terminator: "。" },
|
||||
])("flags a medium-length CJK reply with $label", ({ terminator }) => {
|
||||
const finalText =
|
||||
`第一項設定已完成,請檢查通知狀態${terminator}` +
|
||||
`第二項資料已同步,稍後即可收到訊息${terminator}`;
|
||||
const estimatedChars = estimateStringChars(finalText);
|
||||
|
||||
expect(estimatedChars).toBeGreaterThanOrEqual(120);
|
||||
expect(estimatedChars).toBeLessThan(280);
|
||||
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "accented Latin stays on raw length",
|
||||
finalText: `Le café est prêt. ${"x".repeat(100)}`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
label: "Cyrillic stays on raw length",
|
||||
finalText: `Готово. ${"x".repeat(100)}`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
label: "emoji stays on raw length",
|
||||
finalText: `Done ✅ Shipped 🚀 ${"x".repeat(100)}`,
|
||||
expected: false,
|
||||
},
|
||||
])("non-CJK non-ASCII is unaffected: $label", ({ finalText, expected }) => {
|
||||
expect(finalText.length).toBeLessThan(280);
|
||||
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText })).toBe(expected);
|
||||
});
|
||||
|
||||
it("does not flag empty or whitespace-only final text", () => {
|
||||
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText: "" })).toBe(false);
|
||||
expect(shouldWarnAboutPrivateMessageToolFinal({ ...base, finalText: " \n " })).toBe(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Detects and logs long private finals when message-tool-only delivery was expected. */
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { estimateStringChars } from "../../utils/cjk-chars.js";
|
||||
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
|
||||
import { isSilentReplyText } from "../tokens.js";
|
||||
|
||||
@@ -8,7 +9,8 @@ const privateFinalReplyLogger = createSubsystemLogger("source-reply/private-fina
|
||||
const LONG_PRIVATE_FINAL_MIN_CHARS = 280;
|
||||
const MULTI_SENTENCE_PRIVATE_FINAL_MIN_CHARS = 120;
|
||||
const MULTI_SENTENCE_TERMINATOR_MIN_COUNT = 2;
|
||||
const SENTENCE_TERMINATOR_REGEX = /[.!?]+(?:\s|$)/g;
|
||||
// CJK sentence marks do not require following whitespace; keep ASCII's boundary rule.
|
||||
const SENTENCE_TERMINATOR_REGEX = /[.!?]+(?:\s|$)|[。!?.。]+/gu;
|
||||
|
||||
/**
|
||||
* `message_tool_only` allows the model to stay silent by simply not calling the
|
||||
@@ -34,12 +36,16 @@ export function shouldWarnAboutPrivateMessageToolFinal(params: {
|
||||
if (!trimmed || isSilentReplyText(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.length >= LONG_PRIVATE_FINAL_MIN_CHARS) {
|
||||
// Both thresholds are substance proxies, so they must be compared against the
|
||||
// shared CJK-aware estimate: raw UTF-16 length under-counts CJK about 4x, which
|
||||
// kept substantive CJK finals below both branches and skipped stranded recovery.
|
||||
const estimatedChars = estimateStringChars(trimmed);
|
||||
if (estimatedChars >= LONG_PRIVATE_FINAL_MIN_CHARS) {
|
||||
return true;
|
||||
}
|
||||
const sentenceTerminatorCount = countSentenceLikeTerminators(trimmed);
|
||||
return (
|
||||
trimmed.length >= MULTI_SENTENCE_PRIVATE_FINAL_MIN_CHARS &&
|
||||
estimatedChars >= MULTI_SENTENCE_PRIVATE_FINAL_MIN_CHARS &&
|
||||
sentenceTerminatorCount >= MULTI_SENTENCE_TERMINATOR_MIN_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,34 @@ describe("resolveStrandedReplyRecovery", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the same retry for a substantive CJK private final", () => {
|
||||
// Full-width terminators carry no trailing whitespace, so a CJK reply of the
|
||||
// same shape used to score zero sentence terminators and skip recovery entirely.
|
||||
const base = createMockFollowupRun({ prompt: "question" });
|
||||
const substantiveCjkFinal =
|
||||
"近 7 日營收較前期增加 5.09%,已連續兩週回升。最大風險是集中:前五大站台占正營收 86.5%,已超過 85% 觀察門檻。" +
|
||||
"近 30 日最大單一產品占 44.2%,亦超過 40% 門檻。建議先維持成長節奏並優先降低集中風險,不建議只看總額就全面加碼。" +
|
||||
"成長主因仍待業務確認,我尚未取得該線的回覆。";
|
||||
|
||||
const recovery = resolveStrandedReplyRecovery({
|
||||
base,
|
||||
finalText: substantiveCjkFinal,
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
sendPolicyDenied: false,
|
||||
successfulSourceReplyDelivery: false,
|
||||
isHeartbeat: false,
|
||||
isRoomEvent: false,
|
||||
});
|
||||
|
||||
expect(recovery.kind).toBe("retry");
|
||||
if (recovery.kind === "retry") {
|
||||
expect(recovery.run.strandedReplyRetry).toBe(true);
|
||||
expect(recovery.run.disableCollectBatching).toBe(true);
|
||||
expect(recovery.run.prompt).toContain(substantiveCjkFinal);
|
||||
expect(recovery.run.prompt).toContain("message(action=send)");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a diagnostic rather than a second retry", () => {
|
||||
const base = createMockFollowupRun({ prompt: "question", strandedReplyRetry: true });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user