refactor: reorder post-LLM pipeline to catch think-block repetition loops
Move repetition detection before think-block stripping so degenerate loops inside <think> tags (e.g. 73K char incidents) are caught on raw text. Reorder: token record → repetition detect → think strip → XML tool call extract. Extract StripThinkBlocks and DetectRepetitionLoop into pkg/utils/string and remove the duplicate thinkBlockPattern from the Telegram channel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3012fbfb47
commit
6fad70f05a
4 changed files with 103 additions and 9 deletions
|
|
@ -1547,6 +1547,44 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect repetition loop on raw text (before stripping think
|
||||||
|
// blocks so loops inside <think> are caught). Skip when the
|
||||||
|
// provider already returned native tool calls.
|
||||||
|
if len(response.ToolCalls) == 0 && utils.DetectRepetitionLoop(response.Content) {
|
||||||
|
logger.WarnCF("agent", "Repetition loop detected in LLM response, retrying",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"iteration": iteration,
|
||||||
|
"finish_reason": response.FinishReason,
|
||||||
|
"content_length": len(response.Content),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Retry once: inject nudge message and re-call
|
||||||
|
savedMsgs := messages
|
||||||
|
messages = append(append([]providers.Message(nil), messages...),
|
||||||
|
providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "[System] Your previous response contained degenerate repetition and was discarded. Please respond normally without repeating yourself.",
|
||||||
|
})
|
||||||
|
response, err = callLLM()
|
||||||
|
messages = savedMsgs // restore original messages
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", iteration, fmt.Errorf("LLM retry after repetition failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check on raw text; if still repeating give up
|
||||||
|
if utils.DetectRepetitionLoop(response.Content) {
|
||||||
|
logger.ErrorCF("agent", "Repetition persists after retry, returning empty",
|
||||||
|
map[string]any{"agent_id": agent.ID})
|
||||||
|
response.Content = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip think blocks before extracting XML tool calls so
|
||||||
|
// extraction operates on clean content.
|
||||||
|
response.Content = utils.StripThinkBlocks(response.Content)
|
||||||
|
|
||||||
// Recover XML tool calls emitted as plain text by some providers.
|
// Recover XML tool calls emitted as plain text by some providers.
|
||||||
if len(response.ToolCalls) == 0 {
|
if len(response.ToolCalls) == 0 {
|
||||||
if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 {
|
if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,6 @@ const telegramMaxMessageChars = 3900
|
||||||
const markdownTableMaxWidth = 42
|
const markdownTableMaxWidth = 42
|
||||||
const markdownTableMinColWidth = 6
|
const markdownTableMinColWidth = 6
|
||||||
|
|
||||||
var thinkBlockPattern = regexp.MustCompile(`(?is)<think>.*?</think>`)
|
|
||||||
|
|
||||||
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||||
var opts []telego.BotOption
|
var opts []telego.BotOption
|
||||||
telegramCfg := cfg.Channels.Telegram
|
telegramCfg := cfg.Channels.Telegram
|
||||||
|
|
@ -326,8 +324,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeTelegramOutgoingContent(content string) string {
|
func sanitizeTelegramOutgoingContent(content string) string {
|
||||||
cleaned := thinkBlockPattern.ReplaceAllString(content, "")
|
cleaned := strings.TrimSpace(content)
|
||||||
cleaned = strings.TrimSpace(cleaned)
|
|
||||||
if cleaned == "" {
|
if cleaned == "" {
|
||||||
return "(empty response)"
|
return "(empty response)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSanitizeTelegramOutgoingContent_RemovesThinkBlock(t *testing.T) {
|
func TestSanitizeTelegramOutgoingContent_PlainText(t *testing.T) {
|
||||||
in := "<think>\nsecret reasoning\n</think>\n\nユーザー向け本文"
|
in := " ユーザー向け本文 "
|
||||||
got := sanitizeTelegramOutgoingContent(in)
|
got := sanitizeTelegramOutgoingContent(in)
|
||||||
want := "ユーザー向け本文"
|
want := "ユーザー向け本文"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|
@ -14,9 +14,16 @@ func TestSanitizeTelegramOutgoingContent_RemovesThinkBlock(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeTelegramOutgoingContent_EmptyAfterThink(t *testing.T) {
|
func TestSanitizeTelegramOutgoingContent_Empty(t *testing.T) {
|
||||||
in := "<think>only reasoning</think>"
|
got := sanitizeTelegramOutgoingContent("")
|
||||||
got := sanitizeTelegramOutgoingContent(in)
|
want := "(empty response)"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("sanitizeTelegramOutgoingContent() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeTelegramOutgoingContent_WhitespaceOnly(t *testing.T) {
|
||||||
|
got := sanitizeTelegramOutgoingContent(" \n\t ")
|
||||||
want := "(empty response)"
|
want := "(empty response)"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("sanitizeTelegramOutgoingContent() = %q, want %q", got, want)
|
t.Fatalf("sanitizeTelegramOutgoingContent() = %q, want %q", got, want)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,57 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repetition detection constants.
|
||||||
|
const (
|
||||||
|
repetitionSampleSize = 2000 // runes to sample from the tail
|
||||||
|
repetitionNgramSize = 10 // sliding window length
|
||||||
|
repetitionUniqueThreshold = 0.1 // unique ratio below this → repetition
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
thinkBlockClosedRe = regexp.MustCompile(`(?is)<think>.*?</think>`)
|
||||||
|
thinkBlockOpenRe = regexp.MustCompile(`(?is)<think>.*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// StripThinkBlocks removes <think>…</think> blocks (including unclosed ones)
|
||||||
|
// from s and returns the trimmed result.
|
||||||
|
func StripThinkBlocks(s string) string {
|
||||||
|
s = thinkBlockClosedRe.ReplaceAllString(s, "")
|
||||||
|
s = thinkBlockOpenRe.ReplaceAllString(s, "")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectRepetitionLoop checks if text contains degenerate repetition
|
||||||
|
// by computing the unique N-gram ratio on the last repetitionSampleSize runes.
|
||||||
|
// Returns true if the ratio of unique N-grams to total N-grams
|
||||||
|
// falls below repetitionUniqueThreshold (i.e., 90%+ are duplicates).
|
||||||
|
func DetectRepetitionLoop(text string) bool {
|
||||||
|
runes := []rune(text)
|
||||||
|
|
||||||
|
// Sample the tail
|
||||||
|
if len(runes) > repetitionSampleSize {
|
||||||
|
runes = runes[len(runes)-repetitionSampleSize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(runes) - repetitionNgramSize + 1
|
||||||
|
if total <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
unique := make(map[string]struct{}, total/repetitionNgramSize)
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
ng := string(runes[i : i+repetitionNgramSize])
|
||||||
|
unique[ng] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio := float64(len(unique)) / float64(total)
|
||||||
|
return ratio < repetitionUniqueThreshold
|
||||||
|
}
|
||||||
|
|
||||||
// Truncate returns a truncated version of s with at most maxLen runes.
|
// Truncate returns a truncated version of s with at most maxLen runes.
|
||||||
// Handles multi-byte Unicode characters properly.
|
// Handles multi-byte Unicode characters properly.
|
||||||
// If the string is truncated, "..." is appended to indicate truncation.
|
// If the string is truncated, "..." is appended to indicate truncation.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue