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:
dj-oyu 2026-02-22 15:33:22 +09:00
parent 3012fbfb47
commit 6fad70f05a
4 changed files with 103 additions and 9 deletions

View file

@ -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.
if len(response.ToolCalls) == 0 {
if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 {

View file

@ -51,8 +51,6 @@ const telegramMaxMessageChars = 3900
const markdownTableMaxWidth = 42
const markdownTableMinColWidth = 6
var thinkBlockPattern = regexp.MustCompile(`(?is)<think>.*?</think>`)
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
var opts []telego.BotOption
telegramCfg := cfg.Channels.Telegram
@ -326,8 +324,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
}
func sanitizeTelegramOutgoingContent(content string) string {
cleaned := thinkBlockPattern.ReplaceAllString(content, "")
cleaned = strings.TrimSpace(cleaned)
cleaned := strings.TrimSpace(content)
if cleaned == "" {
return "(empty response)"
}

View file

@ -5,8 +5,8 @@ import (
"testing"
)
func TestSanitizeTelegramOutgoingContent_RemovesThinkBlock(t *testing.T) {
in := "<think>\nsecret reasoning\n</think>\n\nユーザー向け本文"
func TestSanitizeTelegramOutgoingContent_PlainText(t *testing.T) {
in := " ユーザー向け本文 "
got := sanitizeTelegramOutgoingContent(in)
want := "ユーザー向け本文"
if got != want {
@ -14,9 +14,16 @@ func TestSanitizeTelegramOutgoingContent_RemovesThinkBlock(t *testing.T) {
}
}
func TestSanitizeTelegramOutgoingContent_EmptyAfterThink(t *testing.T) {
in := "<think>only reasoning</think>"
got := sanitizeTelegramOutgoingContent(in)
func TestSanitizeTelegramOutgoingContent_Empty(t *testing.T) {
got := sanitizeTelegramOutgoingContent("")
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)"
if got != want {
t.Fatalf("sanitizeTelegramOutgoingContent() = %q, want %q", got, want)

View file

@ -1,5 +1,57 @@
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.
// Handles multi-byte Unicode characters properly.
// If the string is truncated, "..." is appended to indicate truncation.