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.
|
||||
if len(response.ToolCalls) == 0 {
|
||||
if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue