package agent import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "strings" "unicode" "unicode/utf8" ) // Loop detection constants matching the TypeScript implementation. const ( LoopDetectedAbortMessage = "loop detected, stream aborted" LoopDetectedStreakThreshold = 3 LoopDetectedMinNewGramsPerChunk = 8 LoopDetectedProbeChars = 256 ToolLoopDetectedAbortMessage = "tool loop detected, stream aborted" ToolLoopRepeatThreshold = 5 ToolLoopWarningsBeforeAbort = 1 ToolLoopWarningKey = "__memoh_tool_loop_warning" //nolint:gosec // internal warning key, not a credential ToolLoopWarningText = "[MEMOH_TOOL_LOOP_WARNING] Repeated identical tool invocation (same tool + arguments) was detected more than 5 times. Stop looping this tool and either summarize current results or change strategy." //nolint:gosec // human-readable warning text, not a credential defaultNgramSize = 10 defaultWindowSize = 1000 defaultOverlapThreshold = 0.75 defaultConsecutiveHits = 10 defaultMinNewGramsPerChunk = 1 ) // --- Sential: n-gram overlap detector --- // SentialOptions configures the n-gram overlap detector. type SentialOptions struct { NgramSize int WindowSize int OverlapThreshold float64 } // SentialResult is the output of an overlap inspection. type SentialResult struct { Hit bool Overlap float64 MatchedGrams int NewGrams int } // Sential detects text repetition via n-gram overlap. type Sential struct { ngramSize int windowSize int overlapThreshold float64 windowChars []rune windowNgramQueue []string historySet map[string]struct{} historyCounts map[string]int } // NewSential creates a new n-gram overlap detector. func NewSential(opts SentialOptions) *Sential { ngramSize := opts.NgramSize if ngramSize <= 0 { ngramSize = defaultNgramSize } windowSize := opts.WindowSize if windowSize <= 0 { windowSize = defaultWindowSize } overlapThreshold := opts.OverlapThreshold if overlapThreshold <= 0 { overlapThreshold = defaultOverlapThreshold } return &Sential{ ngramSize: ngramSize, windowSize: windowSize, overlapThreshold: overlapThreshold, historySet: make(map[string]struct{}), historyCounts: make(map[string]int), } } func (s *Sential) addHistoryGram(gram string) { s.historyCounts[gram]++ if s.historyCounts[gram] == 1 { s.historySet[gram] = struct{}{} } } func (s *Sential) removeHistoryGram(gram string) { count := s.historyCounts[gram] if count <= 1 { delete(s.historyCounts, gram) delete(s.historySet, gram) return } s.historyCounts[gram] = count - 1 } func (s *Sential) pushWindowChar(ch rune) { s.windowChars = append(s.windowChars, ch) if len(s.windowChars) >= s.ngramSize { start := len(s.windowChars) - s.ngramSize gram := string(s.windowChars[start : start+s.ngramSize]) s.windowNgramQueue = append(s.windowNgramQueue, gram) s.addHistoryGram(gram) } if len(s.windowChars) <= s.windowSize { return } s.windowChars = s.windowChars[1:] if len(s.windowNgramQueue) > 0 { removed := s.windowNgramQueue[0] s.windowNgramQueue = s.windowNgramQueue[1:] s.removeHistoryGram(removed) } } // Inspect checks a chunk of text for n-gram overlap with the sliding window. func (s *Sential) Inspect(text string) SentialResult { incoming := []rune(text) if len(incoming) == 0 { return SentialResult{} } contextSize := s.ngramSize - 1 if contextSize < 0 { contextSize = 0 } var contextChars []rune if contextSize > 0 && len(s.windowChars) > 0 { start := len(s.windowChars) - contextSize if start < 0 { start = 0 } contextChars = make([]rune, len(s.windowChars[start:])) copy(contextChars, s.windowChars[start:]) } candidate := append([]rune{}, contextChars...) candidate = append(candidate, incoming...) contextLength := len(contextChars) matchedGrams := 0 newGrams := 0 if len(candidate) >= s.ngramSize { for i := 0; i <= len(candidate)-s.ngramSize; i++ { gramEndIndex := i + s.ngramSize - 1 if gramEndIndex < contextLength { continue } gram := string(candidate[i : i+s.ngramSize]) newGrams++ if _, ok := s.historySet[gram]; ok { matchedGrams++ } } } overlap := 0.0 if newGrams > 0 { overlap = float64(matchedGrams) / float64(newGrams) } hit := overlap > s.overlapThreshold for _, ch := range incoming { s.pushWindowChar(ch) } return SentialResult{ Hit: hit, Overlap: overlap, MatchedGrams: matchedGrams, NewGrams: newGrams, } } // Reset clears the detector state. func (s *Sential) Reset() { s.windowChars = nil s.windowNgramQueue = nil s.historySet = make(map[string]struct{}) s.historyCounts = make(map[string]int) } // --- Text Loop Guard --- // TextLoopGuardResult extends SentialResult with streak and abort tracking. type TextLoopGuardResult struct { SentialResult Streak int Abort bool } // TextLoopGuard wraps Sential with consecutive-hit tracking. type TextLoopGuard struct { sential *Sential consecutiveHitsToAbort int minNewGramsPerChunk int streak int } // NewTextLoopGuard creates a text loop guard. func NewTextLoopGuard(consecutiveHits, minNewGrams int, opts SentialOptions) *TextLoopGuard { if consecutiveHits <= 0 { consecutiveHits = defaultConsecutiveHits } if minNewGrams <= 0 { minNewGrams = defaultMinNewGramsPerChunk } return &TextLoopGuard{ sential: NewSential(opts), consecutiveHitsToAbort: consecutiveHits, minNewGramsPerChunk: minNewGrams, } } // Inspect checks text and tracks consecutive overlap streaks. func (g *TextLoopGuard) Inspect(text string) TextLoopGuardResult { result := g.sential.Inspect(text) if result.NewGrams >= g.minNewGramsPerChunk { if result.Hit { g.streak++ } else { g.streak = 0 } } return TextLoopGuardResult{ SentialResult: result, Streak: g.streak, Abort: g.streak >= g.consecutiveHitsToAbort, } } // Reset clears the guard state. func (g *TextLoopGuard) Reset() { g.sential.Reset() g.streak = 0 } // --- Text Loop Probe Buffer --- // TextLoopProbeBuffer batches text into chunks before passing to an inspector. type TextLoopProbeBuffer struct { chunkSize int inspect func(string) chars []rune offset int } // NewTextLoopProbeBuffer creates a probe buffer. func NewTextLoopProbeBuffer(chunkSize int, inspect func(string)) *TextLoopProbeBuffer { if chunkSize <= 0 { chunkSize = LoopDetectedProbeChars } return &TextLoopProbeBuffer{ chunkSize: chunkSize, inspect: inspect, } } // Push adds text to the buffer, emitting full chunks to the inspector. func (b *TextLoopProbeBuffer) Push(text string) { if text == "" { return } b.chars = append(b.chars, []rune(text)...) for len(b.chars)-b.offset >= b.chunkSize { chunk := string(b.chars[b.offset : b.offset+b.chunkSize]) b.offset += b.chunkSize if len(chunk) > 0 { b.inspect(chunk) } } if b.offset >= b.chunkSize { b.chars = b.chars[b.offset:] b.offset = 0 } } // Flush emits any remaining content to the inspector. func (b *TextLoopProbeBuffer) Flush() { if len(b.chars)-b.offset > 0 { remainder := string(b.chars[b.offset:]) if len(remainder) > 0 { b.inspect(remainder) } } b.chars = nil b.offset = 0 } // --- Tool Loop Guard --- var defaultVolatileKeys = []string{ "toolcallid", "toolcallid", "requestid", "requestid", "traceid", "traceid", "spanid", "spanid", "sessionid", "sessionid", "timestamp", "createdat", "createdat", "updatedat", "updatedat", "expiresat", "expiresat", "nonce", } var volatileKeySuffixes = []string{ "requestid", "traceid", "sessionid", "toolcallid", "timestamp", "createdat", "updatedat", "expiresat", } // ToolLoopInput represents a tool call for loop detection. type ToolLoopInput struct { ToolName string Input any } // ToolLoopResult is the output of a tool loop inspection. type ToolLoopResult struct { Hash string RepeatCount int BreachCount int Warn bool Abort bool } // ToolLoopGuard detects repeated identical tool calls. type ToolLoopGuard struct { repeatThreshold int warningsBeforeAbort int volatileKeySet map[string]struct{} lastHash string repeatCount int breachCount int breachHash string } // NewToolLoopGuard creates a tool loop guard. func NewToolLoopGuard(repeatThreshold, warningsBeforeAbort int) *ToolLoopGuard { if repeatThreshold <= 0 { repeatThreshold = ToolLoopRepeatThreshold } if warningsBeforeAbort <= 0 { warningsBeforeAbort = ToolLoopWarningsBeforeAbort } volatileSet := make(map[string]struct{}) for _, k := range defaultVolatileKeys { volatileSet[normalizeKeyName(k)] = struct{}{} } return &ToolLoopGuard{ repeatThreshold: repeatThreshold, warningsBeforeAbort: warningsBeforeAbort, volatileKeySet: volatileSet, } } // Inspect checks a tool call for repetition. func (g *ToolLoopGuard) Inspect(input ToolLoopInput) ToolLoopResult { hash := computeToolLoopHash(input, g.volatileKeySet) if hash == g.lastHash { g.repeatCount++ } else { g.lastHash = hash g.repeatCount = 1 } if g.breachHash != hash { g.breachHash = hash g.breachCount = 0 } warn := false abort := false if g.repeatCount > g.repeatThreshold { if g.breachCount < g.warningsBeforeAbort { g.breachCount++ warn = true g.lastHash = "" g.repeatCount = 0 } else { g.breachCount++ abort = true } } return ToolLoopResult{ Hash: hash, RepeatCount: g.repeatCount, BreachCount: g.breachCount, Warn: warn, Abort: abort, } } // Reset clears the guard state. func (g *ToolLoopGuard) Reset() { g.lastHash = "" g.repeatCount = 0 g.breachCount = 0 g.breachHash = "" } func normalizeKeyName(key string) string { var b strings.Builder for _, r := range strings.TrimSpace(strings.ToLower(key)) { if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(r) } } return b.String() } func isVolatileKey(key string, volatileSet map[string]struct{}) bool { normalized := normalizeKeyName(key) if normalized == "" { return false } if _, ok := volatileSet[normalized]; ok { return true } for _, suffix := range volatileKeySuffixes { if strings.HasSuffix(normalized, suffix) { return true } } return false } func normalizeToolLoopValue(value any, volatileSet map[string]struct{}) any { if value == nil { return nil } switch v := value.(type) { case string: return v case bool: return v case float64: return v case json.Number: return v.String() case []any: result := make([]any, len(v)) for i, item := range v { result[i] = normalizeToolLoopValue(item, volatileSet) } return result case map[string]any: keys := make([]string, 0, len(v)) for k := range v { keys = append(keys, k) } sort.Strings(keys) result := make(map[string]any, len(v)) for _, k := range keys { if isVolatileKey(k, volatileSet) { continue } normalized := normalizeToolLoopValue(v[k], volatileSet) if normalized != nil { result[k] = normalized } } return result default: return fmt.Sprintf("%v", v) } } func computeToolLoopHash(input ToolLoopInput, volatileSet map[string]struct{}) string { payload := map[string]any{ "toolName": strings.TrimSpace(input.ToolName), "input": normalizeToolLoopValue(input.Input, volatileSet), } serialized, _ := json.Marshal(payload) h := sha256.Sum256(serialized) return hex.EncodeToString(h[:]) } // --- Helper to check for repetitions in text --- func isNonEmptyString(s string) bool { return utf8.RuneCountInString(strings.TrimSpace(s)) > 0 } // Guard wraps tools with tool loop detection. Returns a wrapper execute function. func (g *ToolLoopGuard) Guard(toolName string, input any) (warn bool, abort bool) { result := g.Inspect(ToolLoopInput{ToolName: toolName, Input: input}) return result.Warn, result.Abort }