mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(agent): dedup repetition output + mark INCOMPLETE instead of NORMAL
This commit is contained in:
parent
fb0ae2b6cc
commit
f71f49bda1
@ -302,8 +302,8 @@ public class NodeStreamingChatHelper {
|
|||||||
* (removed at 42d406ff for being brittle on legitimate long-form
|
* (removed at 42d406ff for being brittle on legitimate long-form
|
||||||
* content), just the cheap specific check that catches this loop.
|
* content), just the cheap specific check that catches this loop.
|
||||||
*/
|
*/
|
||||||
private static final int CONTENT_REPEAT_MIN_PERIOD = 24;
|
public static final int CONTENT_REPEAT_MIN_PERIOD = 24;
|
||||||
private static final int CONTENT_REPEAT_MAX_PERIOD = 240;
|
public static final int CONTENT_REPEAT_MAX_PERIOD = 240;
|
||||||
private static final int CONTENT_REPEAT_MAX_OCCURRENCES = 4;
|
private static final int CONTENT_REPEAT_MAX_OCCURRENCES = 4;
|
||||||
/**
|
/**
|
||||||
* Re-scan every N chars of new content. Smaller = faster reaction,
|
* Re-scan every N chars of new content. Smaller = faster reaction,
|
||||||
@ -1627,6 +1627,49 @@ public class NodeStreamingChatHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse a content buffer's trailing run of verbatim repeats to a
|
||||||
|
* single copy. Used to clean up the persisted final answer after
|
||||||
|
* {@link #hasRepeatingSuffix} fires — the streamed text already
|
||||||
|
* contains the duplicates (SSE chunks can't be unsent), but the
|
||||||
|
* DB-persisted message and the IM channel reply should show ONE
|
||||||
|
* clean copy of the looping unit, not a wall.
|
||||||
|
*
|
||||||
|
* <p>Algorithm: find the smallest period in {@code [minPeriod,
|
||||||
|
* maxPeriod]} where the buffer ends with that unit repeated 2+
|
||||||
|
* times consecutively, then return everything up to (and including)
|
||||||
|
* the FIRST copy of that unit. Conservative — if no period yields
|
||||||
|
* 2+ consecutive matches, returns the buffer unchanged.
|
||||||
|
*
|
||||||
|
* <p>Public for unit-testing alongside {@link #hasRepeatingSuffix}.
|
||||||
|
*/
|
||||||
|
public static String dedupTrailingRepeats(String content, int minPeriod, int maxPeriod) {
|
||||||
|
if (content == null || content.isEmpty()) return content;
|
||||||
|
int len = content.length();
|
||||||
|
if (minPeriod <= 0 || maxPeriod < minPeriod) return content;
|
||||||
|
int periodCap = Math.min(maxPeriod, len / 2);
|
||||||
|
for (int p = minPeriod; p <= periodCap; p++) {
|
||||||
|
int unitStart = len - p;
|
||||||
|
// Walk backward as far as the unit keeps matching.
|
||||||
|
int copies = 1;
|
||||||
|
int blockStart = unitStart - p;
|
||||||
|
while (blockStart >= 0
|
||||||
|
&& content.regionMatches(blockStart, content, unitStart, p)) {
|
||||||
|
copies++;
|
||||||
|
blockStart -= p;
|
||||||
|
}
|
||||||
|
if (copies >= 2) {
|
||||||
|
// Keep prefix + ONE copy. The first copy starts at
|
||||||
|
// (blockStart + p) since the loop walked back one step
|
||||||
|
// past the last match.
|
||||||
|
int firstCopyStart = blockStart + p;
|
||||||
|
int trimEnd = firstCopyStart + p;
|
||||||
|
return content.substring(0, trimEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect whether {@code accum} ends with the same {@code period}-sized
|
* Detect whether {@code accum} ends with the same {@code period}-sized
|
||||||
* unit repeated at least {@code minOccurrences} times consecutively,
|
* unit repeated at least {@code minOccurrences} times consecutively,
|
||||||
|
|||||||
@ -489,6 +489,46 @@ public class ReasoningNode implements NodeAction {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.partial() && "content_repetition".equals(result.errorMessage())) {
|
||||||
|
// Reasoning loop: the helper disposed the stream because the
|
||||||
|
// model emitted the same paragraph 4+ times in a row (qwen3.6
|
||||||
|
// / deepseek-r1 self-arguing pattern). The streamed text
|
||||||
|
// already showed the duplicates to the user — we can't unsend
|
||||||
|
// SSE chunks — but the persisted finalAnswer should be ONE
|
||||||
|
// clean copy so the IM channel reply and any page-reload
|
||||||
|
// history don't show the wall of repetition. Skip
|
||||||
|
// FinalAnswerNode's evidence validation: the answer is
|
||||||
|
// already truncated, applying validateAnswer on top would
|
||||||
|
// double-stamp warnings on something the user already knows
|
||||||
|
// is incomplete.
|
||||||
|
String rawContent = result.text() != null ? result.text() : "";
|
||||||
|
String dedupedAnswer = NodeStreamingChatHelper.dedupTrailingRepeats(
|
||||||
|
rawContent,
|
||||||
|
NodeStreamingChatHelper.CONTENT_REPEAT_MIN_PERIOD,
|
||||||
|
NodeStreamingChatHelper.CONTENT_REPEAT_MAX_PERIOD);
|
||||||
|
log.warn("[ReasoningNode] Content-repetition cap hit (raw={} chars → deduped={} chars); " +
|
||||||
|
"INCOMPLETE",
|
||||||
|
rawContent.length(), dedupedAnswer.length());
|
||||||
|
var builder = reasonOutput()
|
||||||
|
.needsToolCall(false)
|
||||||
|
.shouldSummarize(false)
|
||||||
|
.finalAnswer(dedupedAnswer.isEmpty()
|
||||||
|
? "(模型反复输出同一段内容,已自动截断。请尝试重新生成或换个问法。)"
|
||||||
|
: dedupedAnswer)
|
||||||
|
.llmCallCount(nextLlmCallCount)
|
||||||
|
.finishReason(FinishReason.INCOMPLETE)
|
||||||
|
// contentStreamed=true because the user already saw
|
||||||
|
// the looping text in their bubble; persisting again
|
||||||
|
// via streamedContent would replay it.
|
||||||
|
.contentStreamed(true)
|
||||||
|
.thinkingStreamed(result.thinking() != null && !result.thinking().isEmpty())
|
||||||
|
.mergeUsage(state, result);
|
||||||
|
if (result.thinking() != null && !result.thinking().isEmpty()) {
|
||||||
|
builder.finalThinking(result.thinking());
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
// Fatal error:直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK,
|
// Fatal error:直接设置 finalAnswer 为错误文案 + ERROR_FALLBACK,
|
||||||
// 不走 LimitExceededNode(后者会再发一次 LLM 调用,语义不对且对认证/配额错误会再失败)。
|
// 不走 LimitExceededNode(后者会再发一次 LLM 调用,语义不对且对认证/配额错误会再失败)。
|
||||||
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize → finalAnswerNode,
|
// ReasoningDispatcher 看到 !needsToolCall && !shouldSummarize → finalAnswerNode,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user