1package core
2
3import (
4 "crypto/sha256"
5 "encoding/binary"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "hash"
11 "strings"
12 "time"
13
14 "github.com/bnema/zut/packages/provider"
15)
16
17// ErrRepetitiveLoop reports that an agent repeated identical work after being
18// warned to try a different approach. It matches with errors.Is.
19var ErrRepetitiveLoop = errors.New("agent stopped after detecting a repetitive loop")
20
21const (
22 repetitionWarnThreshold = 5
23 repetitionStopThreshold = 8
24 repetitionWindowSize = 64
25
26 // RepetitionGuardMetaKey marks the hidden prompt added when the agent
27 // detects repeated content.
28 RepetitionGuardMetaKey = "zut_repetition_guard"
29)
30
31// RepetitionKind identifies the exact content pattern that repeated.
32type RepetitionKind string
33
34const (
35 RepetitionKindToolCall RepetitionKind = "tool_call_result"
36 RepetitionKindAssistantMessage RepetitionKind = "assistant_message"
37)
38
39// RepetitionGuardStage identifies a warning or a terminal stop.
40type RepetitionGuardStage string
41
42const (
43 RepetitionGuardWarning RepetitionGuardStage = "warning"
44 RepetitionGuardStopped RepetitionGuardStage = "stopped"
45)
46
47type repetitionObservation struct {
48 fingerprint string
49 kind RepetitionKind
50 toolName string
51 at time.Time
52}
53
54type repetitionPattern struct {
55 count int
56 firstSeen time.Time
57 warned bool
58 kind RepetitionKind
59 toolName string
60}
61
62type repetitionGuardState struct {
63 patterns map[string]repetitionPattern
64 history []repetitionObservation
65 stopped bool
66 stoppedDecision repetitionDecision
67}
68
69type repetitionDecision struct {
70 stage RepetitionGuardStage
71 kind RepetitionKind
72 toolName string
73 count int
74 elapsed time.Duration
75}
76
77// observeRepetition counts one exact content fingerprint in a bounded sliding
78// window. Fingerprints contain no call ID, timing, or raw content, so repeated
79// executions can match without retaining private tool arguments or output.
80func (a *Agent) observeRepetition(fingerprint string, kind RepetitionKind, toolName string) repetitionDecision {
81 if fingerprint == "" || a.DisableRepetitionGuard {
82 return repetitionDecision{}
83 }
84 now := time.Now()
85 a.mu.Lock()
86 defer a.mu.Unlock()
87 if a.repetition.stopped {
88 decision := a.repetition.stoppedDecision
89 decision.stage = RepetitionGuardStopped
90 return decision
91 }
92 if a.repetition.patterns == nil {
93 a.repetition.patterns = make(map[string]repetitionPattern)
94 }
95 a.repetition.history = append(a.repetition.history, repetitionObservation{
96 fingerprint: fingerprint,
97 kind: kind,
98 toolName: toolName,
99 at: now,
100 })
101 if len(a.repetition.history) > repetitionWindowSize {
102 oldest := a.repetition.history[0]
103 a.repetition.history[0] = repetitionObservation{}
104 a.repetition.history = a.repetition.history[1:]
105 oldPattern := a.repetition.patterns[oldest.fingerprint]
106 oldPattern.count--
107 if oldPattern.count <= 0 {
108 delete(a.repetition.patterns, oldest.fingerprint)
109 } else {
110 if oldPattern.count < repetitionWarnThreshold {
111 oldPattern.warned = false
112 }
113 oldPattern.firstSeen = firstRepetitionSeen(a.repetition.history, oldest.fingerprint)
114 a.repetition.patterns[oldest.fingerprint] = oldPattern
115 }
116 }
117 pattern := a.repetition.patterns[fingerprint]
118 if pattern.count == 0 {
119 pattern.firstSeen = now
120 }
121 pattern.count++
122 pattern.kind = kind
123 if toolName != "" {
124 pattern.toolName = toolName
125 }
126 a.repetition.patterns[fingerprint] = pattern
127 decision := repetitionDecision{
128 kind: pattern.kind,
129 toolName: pattern.toolName,
130 count: pattern.count,
131 elapsed: now.Sub(pattern.firstSeen),
132 }
133 if pattern.count >= repetitionStopThreshold && pattern.warned {
134 a.repetition.stopped = true
135 decision.stage = RepetitionGuardStopped
136 a.repetition.stoppedDecision = decision
137 return decision
138 }
139 if pattern.count >= repetitionWarnThreshold && !pattern.warned {
140 pattern.warned = true
141 a.repetition.patterns[fingerprint] = pattern
142 decision.stage = RepetitionGuardWarning
143 }
144 return decision
145}
146
147func firstRepetitionSeen(history []repetitionObservation, fingerprint string) time.Time {
148 for _, observation := range history {
149 if observation.fingerprint == fingerprint {
150 return observation.at
151 }
152 }
153 return time.Now()
154}
155
156func (a *Agent) repetitionStopped() bool {
157 a.mu.Lock()
158 defer a.mu.Unlock()
159 return a.repetition.stopped && !a.DisableRepetitionGuard
160}
161
162func (a *Agent) repetitiveLoopError() error {
163 a.mu.Lock()
164 decision := a.repetition.stoppedDecision
165 a.mu.Unlock()
166 if decision.toolName != "" {
167 return fmt.Errorf("%w: tool %q repeated %d times with identical results over %s", ErrRepetitiveLoop, decision.toolName, decision.count, decision.elapsed.Round(time.Second))
168 }
169 return fmt.Errorf("%w: identical assistant message repeated %d times over %s", ErrRepetitiveLoop, decision.count, decision.elapsed.Round(time.Second))
170}
171
172func (a *Agent) handleRepetitionDecision(decision repetitionDecision, sink func(AgentEvent)) bool {
173 if decision.stage == RepetitionGuardWarning {
174 prompt := "You appear to be stuck in a loop: the same assistant message has repeated. Try a substantively different action or workaround. If you are blocked, explain the blocker and ask the user for help instead of repeating the same work."
175 if decision.toolName != "" {
176 prompt = fmt.Sprintf("You appear to be stuck repeating tool %q with identical arguments and results (%d repetitions). Try a different action or workaround; if blocked, ask the user for help instead of repeating the tool.", decision.toolName, decision.count)
177 }
178 a.AppendUserContext(prompt, map[string]string{RepetitionGuardMetaKey: "true"})
179 }
180 sink(EvRepetitionGuard{
181 Kind: decision.kind,
182 Stage: decision.stage,
183 Count: decision.count,
184 Elapsed: decision.elapsed,
185 })
186 if decision.stage != RepetitionGuardStopped {
187 return false
188 }
189 sink(EvDone{})
190 return true
191}
192
193func assistantMessageFingerprint(message provider.Message) string {
194 h := sha256.New()
195 writeRepetitionField(h, []byte("assistant-message"))
196 var hasText bool
197 for _, content := range message.Content {
198 text, ok := content.(provider.TextBlock)
199 if !ok || strings.TrimSpace(text.Text) == "" {
200 continue
201 }
202 hasText = true
203 writeRepetitionField(h, []byte(text.Text))
204 }
205 if !hasText {
206 return ""
207 }
208 return hex.EncodeToString(h.Sum(nil))
209}
210
211func toolCallResultFingerprint(call provider.ToolCallBlock, result provider.ToolResultBlock) string {
212 h := sha256.New()
213 writeRepetitionField(h, []byte("tool-call-result"))
214 writeRepetitionField(h, []byte(call.Name))
215 writeRepetitionField(h, canonicalRepetitionJSON(call.Arguments))
216 if result.IsError {
217 writeRepetitionField(h, []byte{1})
218 } else {
219 writeRepetitionField(h, []byte{0})
220 }
221 for _, content := range result.Content {
222 switch block := content.(type) {
223 case provider.TextBlock:
224 writeRepetitionField(h, []byte("text"))
225 writeRepetitionField(h, []byte(block.Text))
226 writeRepetitionField(h, []byte(block.ThoughtSignature))
227 writeRepetitionField(h, []byte(block.Phase))
228 case provider.ImageBlock:
229 writeRepetitionField(h, []byte("image"))
230 writeRepetitionField(h, []byte(block.MimeType))
231 writeRepetitionField(h, block.Data)
232 writeRepetitionField(h, []byte(block.ThoughtSignature))
233 case provider.ToolCallBlock:
234 writeRepetitionField(h, []byte("tool_call"))
235 writeRepetitionField(h, []byte(block.Name))
236 writeRepetitionField(h, canonicalRepetitionJSON(block.Arguments))
237 case provider.ToolResultBlock:
238 writeRepetitionField(h, []byte("tool_result"))
239 if block.IsError {
240 writeRepetitionField(h, []byte{1})
241 } else {
242 writeRepetitionField(h, []byte{0})
243 }
244 for _, nested := range block.Content {
245 if encoded, err := json.Marshal(nested); err == nil {
246 writeRepetitionField(h, encoded)
247 }
248 }
249 case provider.ReasoningBlock:
250 writeRepetitionField(h, []byte("reasoning"))
251 writeRepetitionField(h, []byte(block.ID))
252 writeRepetitionField(h, []byte(block.Summary))
253 writeRepetitionField(h, []byte(block.Encrypted))
254 default:
255 encoded, err := json.Marshal(content)
256 if err != nil {
257 writeRepetitionField(h, []byte(fmt.Sprintf("%T", content)))
258 } else {
259 writeRepetitionField(h, encoded)
260 }
261 }
262 }
263 return hex.EncodeToString(h.Sum(nil))
264}
265
266func writeRepetitionField(h hash.Hash, value []byte) {
267 var size [8]byte
268 binary.BigEndian.PutUint64(size[:], uint64(len(value)))
269 _, _ = h.Write(size[:])
270 _, _ = h.Write(value)
271}
272
273func repetitionCallObservations(assistant, toolMessage provider.Message) []repetitionObservation {
274 results := make(map[string]provider.ToolResultBlock, len(toolMessage.Content))
275 for _, content := range toolMessage.Content {
276 if result, ok := content.(provider.ToolResultBlock); ok {
277 results[result.CallID] = result
278 }
279 }
280 observations := make([]repetitionObservation, 0, len(results))
281 seen := make(map[string]bool, len(results))
282 for _, content := range assistant.Content {
283 call, ok := content.(provider.ToolCallBlock)
284 if !ok {
285 continue
286 }
287 result, ok := results[call.ID]
288 if !ok {
289 continue
290 }
291 fingerprint := toolCallResultFingerprint(call, result)
292 if seen[fingerprint] {
293 continue
294 }
295 seen[fingerprint] = true
296 observations = append(observations, repetitionObservation{
297 fingerprint: fingerprint,
298 kind: RepetitionKindToolCall,
299 toolName: call.Name,
300 })
301 }
302 return observations
303}
304
305func canonicalRepetitionJSON(value json.RawMessage) []byte {
306 decoder := json.NewDecoder(strings.NewReader(string(value)))
307 decoder.UseNumber()
308 var decoded any
309 if err := decoder.Decode(&decoded); err != nil {
310 return value
311 }
312 canonical, err := json.Marshal(decoded)
313 if err != nil {
314 return value
315 }
316 return canonical
317}