diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e2d06eb44..881f5ec1d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1547,6 +1547,44 @@ func (al *AgentLoop) runLLMIteration( ) } + // Detect repetition loop on raw text (before stripping think + // blocks so loops inside 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 { diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 006daf11d..80d09b82b 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -51,8 +51,6 @@ const telegramMaxMessageChars = 3900 const markdownTableMaxWidth = 42 const markdownTableMinColWidth = 6 -var thinkBlockPattern = regexp.MustCompile(`(?is).*?`) - 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)" } diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go index 39b59d0e3..3f3ccaca8 100644 --- a/pkg/channels/telegram_test.go +++ b/pkg/channels/telegram_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -func TestSanitizeTelegramOutgoingContent_RemovesThinkBlock(t *testing.T) { - in := "\nsecret reasoning\n\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 := "only reasoning" - 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) diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 7a6aa37cc..10623d398 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -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).*?`) + thinkBlockOpenRe = regexp.MustCompile(`(?is).*$`) +) + +// StripThinkBlocks removes 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.