v0.51.0 a9d674e

repetition.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close