Skip to content

Commit 42f8c62

Browse files
krissdingclaude
andcommitted
fix: suppress trailing post-message-tool meta-acknowledgements in final reply
After an agent calls the message tool (action=send), models often append short trailing meta-acks ("已发 #22141", "Sent above", "OK", "Roger") that escape the existing 10-char dedupe floor and appear as a second visible message in the channel. Add isPostToolSendMetaCommentary with full-text anchored patterns plus compound ack detection (sentence-boundary split, every segment must independently match ack patterns). Wire filterMessagingToolMetaCommentary into both agent-runner-payloads and followup-delivery paths after the existing content dedupe. Preserves mixed-content forms like "OK, that's the fix." and media payloads. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 04e4c37 commit 42f8c62

8 files changed

Lines changed: 349 additions & 3 deletions

File tree

src/agents/embedded-agent-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export { sanitizeSessionMessagesImages } from "./embedded-agent-helpers/images.j
6464
export {
6565
isMessagingToolDuplicate,
6666
isMessagingToolDuplicateNormalized,
67+
isPostToolSendMetaCommentary,
6768
normalizeTextForComparison,
6869
} from "./embedded-agent-helpers/messaging-dedupe.js";
6970

src/agents/embedded-agent-helpers/messaging-dedupe.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
55

66
const MIN_DUPLICATE_TEXT_LENGTH = 10;
77
const MIN_REVERSE_SUBSTRING_DUPLICATE_RATIO = 0.5;
8+
// Maximum length for a "post-tool-send meta commentary" candidate. Real
9+
// replies are longer than this; meta-acks ("已发 #22141", "Sent above")
10+
// are short trailing acknowledgements after a message-tool send.
11+
const MAX_META_COMMENTARY_LENGTH = 200;
12+
// Sentence-boundary delimiters for compound ack splitting.
13+
const SENTENCE_BOUNDARY_RE = /[.;,!\n]\s*/g;
14+
15+
/**
16+
* Patterns that match a post-tool-send meta-acknowledgement phrase.
17+
*
18+
* Each pattern is anchored (`^...$`) and matches the ack phrase followed
19+
* **only** by trailing punctuation, whitespace, parenthesised message refs
20+
* (`(#22142)`), or numeric refs (`#22141`). The ack phrase itself must
21+
* stand alone as the dominant content — prefix-only matches are rejected
22+
* so that real replies like `Oklahoma weather`, `sentence fixed`,
23+
* `已发现问题`, `okay let's go` are NOT suppressed.
24+
*
25+
* Compound meta-acks (`Sent. Replied in thread.`, `已发, 不再追加总结`,
26+
* `OK. Done.`) are detected by splitting on sentence boundaries and
27+
* checking that every segment independently matches a pattern from this
28+
* list. See `isCompoundMetaCommentary`.
29+
*/
30+
const META_COMMENTARY_PATTERNS: readonly RegExp[] = [
31+
// Chinese standalone acks: "已发", "已发送", "主回复已发", "好了", "收到"
32+
/^(?:(?:||)?||(?:|)?||?||||||)[\s.,!;?()#\d]*$/u,
33+
// Chinese mid-text acks: "核心回答如下", "总结如下", "不再追加"
34+
/^(?:||(?:|)?||(?:|)?||||)[\s.,!;?()#\d]*$/u,
35+
// English standalone acks: "OK", "Sent", "Done", "Roger", "Got it", ...
36+
/^(?:sent(?:\s+(?:above|#\d+|\([^)]+\)))?|done\.?|replied(?:\s+(?:above|in\s+thread))?|see\s+above|as\s+above|posted\.?|acknowledged\.?|ok(?:ay)?|got\s+it|gotcha|roger|cop(?:y|ied)(?:\s+that)?|ack(?:nowledged)?|will\s+do|on\s+it|noted|understood|thanks|thx)[\s.,!;?()#\d]*$/i,
37+
// English mid-text: "Replying above", "Answer below", "Response above"
38+
/^(?:replying\s+(?:above|below|in\s+thread)|answer\s+below|response\s+above|sent\s+in\s+thread)[\s.,!;?()#\d]*$/i,
39+
];
840

941
/**
1042
* Normalize text for duplicate comparison.
@@ -20,6 +52,60 @@ export function normalizeTextForComparison(text: string): string {
2052
.trim();
2153
}
2254

55+
/**
56+
* Detect compound meta-acks where every sentence-boundary-delimited
57+
* segment is itself a short meta-acknowledgement.
58+
*
59+
* Used as a second pass after the full-text anchored pattern match
60+
* so that multi-sentence acks like `Sent. Replied in thread.` and
61+
* `已发, 不再追加总结` are caught, while mixed-form texts like
62+
* `OK, that's the fix.` or `已发. Now let me explain...` are
63+
* preserved because at least one segment does not match any ack
64+
* pattern.
65+
*/
66+
function isCompoundMetaCommentary(normalized: string): boolean {
67+
const segments = normalized.split(SENTENCE_BOUNDARY_RE).filter((s) => s.trim().length > 0);
68+
if (segments.length < 2) return false;
69+
// Every segment must independently match a meta-ack pattern.
70+
// A single non-matching segment means the text has real content.
71+
return segments.every((segment) =>
72+
META_COMMENTARY_PATTERNS.some((pattern) => pattern.test(segment.trim())),
73+
);
74+
}
75+
76+
/**
77+
* Detect short post-tool-send meta commentary.
78+
*
79+
* After an agent runs a message-tool send in the same turn, models
80+
* sometimes append a trailing acknowledgement like "已发 #22141",
81+
* "Sent above", "核心回答如下", "OK", "Roger" or "Got it". These
82+
* acks add no information beyond the message-tool send itself and
83+
* reach the channel as a second visible message — a well-known
84+
* duplicate-reply pattern.
85+
*
86+
* Detection is intentionally conservative:
87+
* - Normalized text must be ≤ MAX_META_COMMENTARY_LENGTH chars
88+
* - Full-text anchored pattern match is attempted first (standalone
89+
* acks like `已发`, `OK`, `Sent above`)
90+
* - If that fails, compound ack detection splits on sentence
91+
* boundaries and verifies every segment is itself ack-like
92+
* - Used only when a message-tool send has already happened this
93+
* turn (caller responsibility — see
94+
* `filterMessagingToolMetaCommentary`)
95+
*/
96+
export function isPostToolSendMetaCommentary(text: string): boolean {
97+
const normalized = normalizeTextForComparison(text);
98+
if (!normalized || normalized.length > MAX_META_COMMENTARY_LENGTH) {
99+
return false;
100+
}
101+
// Full-text anchored match first (standalone acks).
102+
if (META_COMMENTARY_PATTERNS.some((pattern) => pattern.test(normalized))) {
103+
return true;
104+
}
105+
// Second pass: compound ack detection.
106+
return isCompoundMetaCommentary(normalized);
107+
}
108+
23109
/** Compare already-normalized message text against prior sends. */
24110
export function isMessagingToolDuplicateNormalized(
25111
normalized: string,

src/auto-reply/reply/agent-runner-payloads.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,11 @@ export async function buildReplyPayloads(params: {
347347
payloads: mediaFiltered,
348348
sentTexts,
349349
});
350-
dedupedPayloads.push(...textFiltered);
350+
const metaFiltered = dedupeRuntime.filterMessagingToolMetaCommentary({
351+
payloads: textFiltered,
352+
sentTexts,
353+
});
354+
dedupedPayloads.push(...metaFiltered);
351355
}
352356
}
353357
const directlySentTextFragmentsByAssistantMessage = new Map<number | undefined, string[]>();

src/auto-reply/reply/followup-delivery.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
applyReplyThreading,
2020
filterMessagingToolDuplicates,
2121
filterMessagingToolMediaDuplicates,
22+
filterMessagingToolMetaCommentary,
2223
resolveMessagingToolPayloadDedupe,
2324
} from "./reply-payloads.js";
2425
import { createReplyDeliveryContext, resolveReplyToMode } from "./reply-threading.js";
@@ -134,7 +135,11 @@ export function resolveFollowupDeliveryPayloads(params: {
134135
payloads: mediaFiltered,
135136
sentTexts,
136137
});
137-
dedupedPayloads.push(...textFiltered);
138+
const metaFiltered = filterMessagingToolMetaCommentary({
139+
payloads: textFiltered,
140+
sentTexts,
141+
});
142+
dedupedPayloads.push(...metaFiltered);
138143
}
139144
return dedupedPayloads;
140145
}

src/auto-reply/reply/reply-payloads-dedupe.runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
export {
33
filterMessagingToolDuplicates,
44
filterMessagingToolMediaDuplicates,
5+
filterMessagingToolMetaCommentary,
56
resolveMessagingToolPayloadDedupe,
67
shouldDedupeMessagingToolRepliesForRoute,
78
type MessagingToolPayloadDedupeDecision,

src/auto-reply/reply/reply-payloads-dedupe.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import {
33
normalizeLowercaseStringOrEmpty,
44
normalizeOptionalString,
55
} from "@openclaw/normalization-core/string-coerce";
6-
import { isMessagingToolDuplicate } from "../../agents/embedded-agent-helpers.js";
6+
import {
7+
isMessagingToolDuplicate,
8+
isPostToolSendMetaCommentary,
9+
} from "../../agents/embedded-agent-helpers.js";
10+
11+
export { isPostToolSendMetaCommentary };
712
import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js";
813
import { getChannelPlugin } from "../../channels/plugins/index.js";
914
import { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
@@ -101,6 +106,39 @@ export function filterMessagingToolMediaDuplicates(params: {
101106
return nextPayloads ?? payloads;
102107
}
103108

109+
/**
110+
* Removes trailing payloads that are short post-tool-send meta commentary.
111+
*
112+
* After an agent calls the message tool to deliver its main reply, models
113+
* sometimes append a brief acknowledgement ("已发 #22141", "Sent above",
114+
* "核心回答如下", "OK", "Roger"). These acks reach the channel as a second
115+
* visible message and are a well-known duplicate-reply pattern. This filter
116+
* only fires when the run already produced message-tool sends (`sentTexts`
117+
* non-empty) — the agent has actually delivered its main reply and any
118+
* final text that matches a meta-ack pattern is suppressing itself.
119+
*/
120+
export function filterMessagingToolMetaCommentary(params: {
121+
payloads: ReplyPayload[];
122+
sentTexts: string[];
123+
}): ReplyPayload[] {
124+
const { payloads, sentTexts } = params;
125+
if (sentTexts.length === 0) {
126+
return payloads;
127+
}
128+
return payloads.filter((payload) => {
129+
if (payload.mediaUrl || payload.mediaUrls?.length) {
130+
// Media payloads always carry new information, even if the caption is
131+
// meta-ack; never suppress them.
132+
return true;
133+
}
134+
const text = payload.text ?? "";
135+
if (!text) {
136+
return true;
137+
}
138+
return !isPostToolSendMetaCommentary(text);
139+
});
140+
}
141+
104142
function normalizeMediaForDedupe(value: string): string {
105143
const trimmed = value.trim();
106144
if (!trimmed) {

0 commit comments

Comments
 (0)