返回 DeepSeek-Reasonix
repeat_guard_test.go
根目录 / internal / agent / repeat_guard_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync/atomic"
11 "testing"
12
13 "reasonix/internal/diff"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 "reasonix/internal/tool/builtin"
18 )
19
20 type failingWriterTool struct {
21 name string
22 calls *int32
23 }
24
25 func (f failingWriterTool) Name() string { return f.name }
26 func (f failingWriterTool) Description() string { return "always fails to write" }
27 func (f failingWriterTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
28 func (f failingWriterTool) ReadOnly() bool { return false }
29 func (f failingWriterTool) Execute(context.Context, json.RawMessage) (string, error) {
30 if f.calls != nil {
31 atomic.AddInt32(f.calls, 1)
32 }
33 return "", errors.New("old_string not found in prompt.txt")
34 }
35
36 type stateAwareWriterTool struct {
37 name string
38 calls *int32
39 valid *atomic.Bool
40 }
41
42 type previewSuccessFailWriterTool struct {
43 name string
44 calls *int32
45 }
46
47 func (f previewSuccessFailWriterTool) Name() string { return f.name }
48 func (f previewSuccessFailWriterTool) Description() string {
49 return "previews successfully but cannot write"
50 }
51 func (f previewSuccessFailWriterTool) Schema() json.RawMessage {
52 return json.RawMessage(`{"type":"object"}`)
53 }
54 func (f previewSuccessFailWriterTool) ReadOnly() bool { return false }
55 func (f previewSuccessFailWriterTool) Execute(context.Context, json.RawMessage) (string, error) {
56 if f.calls != nil {
57 atomic.AddInt32(f.calls, 1)
58 }
59 return "", errors.New("write prompt.txt: permission denied")
60 }
61 func (f previewSuccessFailWriterTool) Preview(json.RawMessage) (diff.Change, error) {
62 return diff.Change{Path: "prompt.txt"}, nil
63 }
64
65 func (f stateAwareWriterTool) Name() string { return f.name }
66 func (f stateAwareWriterTool) Description() string { return "fails until target state changes" }
67 func (f stateAwareWriterTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
68 func (f stateAwareWriterTool) ReadOnly() bool { return false }
69 func (f stateAwareWriterTool) Execute(context.Context, json.RawMessage) (string, error) {
70 if f.calls != nil {
71 atomic.AddInt32(f.calls, 1)
72 }
73 if f.valid != nil && f.valid.Load() {
74 return "edited prompt.txt", nil
75 }
76 return "", errors.New("old_string not found in prompt.txt")
77 }
78 func (f stateAwareWriterTool) Preview(json.RawMessage) (diff.Change, error) {
79 if f.valid != nil && f.valid.Load() {
80 return diff.Change{Path: "prompt.txt"}, nil
81 }
82 return diff.Change{}, errors.New("old_string not found in prompt.txt")
83 }
84
85 func TestRepeatGuardBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
86 var calls int32
87 reg := tool.NewRegistry()
88 reg.Add(fakeTool{name: "bash", readOnly: false, calls: &calls})
89 args := `{"command":"python -c \"with open('prompt.txt', 'w') as f: f.write('hello')\""}`
90 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
91 {toolCallChunk("c1", "bash", args), {Type: provider.ChunkDone}},
92 {toolCallChunk("c2", "bash", args), {Type: provider.ChunkDone}},
93 {toolCallChunk("c3", "bash", args), {Type: provider.ChunkDone}},
94 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
95 }}
96 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
97
98 if err := a.Run(context.Background(), "update the prompt file"); err != nil {
99 t.Fatalf("Run: %v", err)
100 }
101 if got := atomic.LoadInt32(&calls); got != 2 {
102 t.Fatalf("bash executed %d times, want 2 before the repeat guard blocks", got)
103 }
104 results := toolResults(a.session, "bash")
105 if len(results) != 3 {
106 t.Fatalf("tool results = %d, want 3", len(results))
107 }
108 last := results[len(results)-1]
109 if !strings.Contains(last, "[loop guard]") || !strings.Contains(last, "edit_file") {
110 t.Fatalf("third repeated write should nudge the model to change tools, got %q", last)
111 }
112 }
113
114 func TestRepeatGuardAllowsRepeatedNonWritingBashCommand(t *testing.T) {
115 var calls int32
116 reg := tool.NewRegistry()
117 reg.Add(fakeTool{name: "bash", readOnly: false, calls: &calls})
118 args := `{"command":"go test ./internal/agent"}`
119 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
120 {toolCallChunk("c1", "bash", args), {Type: provider.ChunkDone}},
121 {toolCallChunk("c2", "bash", args), {Type: provider.ChunkDone}},
122 {toolCallChunk("c3", "bash", args), {Type: provider.ChunkDone}},
123 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
124 }}
125 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
126
127 if err := a.Run(context.Background(), "verify repeatedly"); err != nil {
128 t.Fatalf("Run: %v", err)
129 }
130 if got := atomic.LoadInt32(&calls); got != 3 {
131 t.Fatalf("bash executed %d times, want 3 for non-writing commands", got)
132 }
133 if last := lastToolResult(a.session, "bash"); strings.Contains(last, "[loop guard]") {
134 t.Fatalf("non-writing bash should not trip the repeat guard, got %q", last)
135 }
136 }
137
138 func TestRepeatGuardBashWriteRedirectDetectionUsesAST(t *testing.T) {
139 tests := []struct {
140 name string
141 cmd string
142 want bool
143 }{
144 {
145 name: "stdout file redirect",
146 cmd: "printf hi > prompt.txt",
147 want: true,
148 },
149 {
150 name: "stderr file redirect",
151 cmd: "printf hi 2>err.log",
152 want: true,
153 },
154 {
155 name: "append redirect",
156 cmd: "printf hi >> prompt.txt",
157 want: true,
158 },
159 {
160 name: "combined redirect",
161 cmd: "printf hi &> prompt.txt",
162 want: true,
163 },
164 {
165 name: "read write redirect",
166 cmd: "cat <> prompt.txt",
167 want: true,
168 },
169 {
170 name: "null sink redirect",
171 cmd: "printf hi >/dev/null",
172 want: false,
173 },
174 {
175 name: "powershell null sink spelling",
176 cmd: "printf hi >$null",
177 want: false,
178 },
179 {
180 name: "windows nul sink spelling",
181 cmd: "printf hi >NUL",
182 want: false,
183 },
184 {
185 name: "fd duplication",
186 cmd: "printf hi 2>&1",
187 want: false,
188 },
189 {
190 name: "quoted redirect text",
191 cmd: `printf '%s\n' 'a > b'`,
192 want: false,
193 },
194 {
195 name: "heredoc body redirect text",
196 cmd: "cat <<'EOF'\n> prompt.txt\nEOF",
197 want: false,
198 },
199 {
200 name: "heredoc with file redirect",
201 cmd: "cat <<'EOF' > prompt.txt\nbody\nEOF",
202 want: true,
203 },
204 }
205
206 for _, tt := range tests {
207 t.Run(tt.name, func(t *testing.T) {
208 if got := isShellFileWriteCommand(tt.cmd); got != tt.want {
209 t.Fatalf("isShellFileWriteCommand(%q) = %v, want %v", tt.cmd, got, tt.want)
210 }
211 })
212 }
213 }
214
215 func TestRepeatGuardNormalizesStaticBashFields(t *testing.T) {
216 singleQuoted := normalizeShellCommand(`printf '%s\n' 'hello world'`)
217 doubleQuoted := normalizeShellCommand(`printf "%s\n" "hello world"`)
218 if singleQuoted != doubleQuoted {
219 t.Fatalf("normalized quote styles differ:\n single: %q\n double: %q", singleQuoted, doubleQuoted)
220 }
221 }
222
223 func TestRepeatGuardAllowsTwoRepeatedWriterSuccesses(t *testing.T) {
224 var calls int32
225 reg := tool.NewRegistry()
226 reg.Add(fakeTool{name: "write_file", readOnly: false, calls: &calls})
227 args := `{"path":"prompt.txt","content":"hello"}`
228 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
229 {toolCallChunk("c1", "write_file", args), {Type: provider.ChunkDone}},
230 {toolCallChunk("c2", "write_file", args), {Type: provider.ChunkDone}},
231 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
232 }}
233 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
234
235 if err := a.Run(context.Background(), "write twice"); err != nil {
236 t.Fatalf("Run: %v", err)
237 }
238 if got := atomic.LoadInt32(&calls); got != 2 {
239 t.Fatalf("writer executed %d times, want 2 before the guard threshold", got)
240 }
241 if last := lastToolResult(a.session, "write_file"); strings.Contains(last, "[loop guard]") {
242 t.Fatalf("second repeated writer call should still be allowed, got %q", last)
243 }
244 }
245
246 func TestRepeatGuardBlocksStaleEditLoopAcrossSuccessfulReads(t *testing.T) {
247 var editCalls int32
248 var readCalls int32
249 reg := tool.NewRegistry()
250 reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
251 reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &readCalls})
252 editArgs1 := `{"path":"prompt.txt","old_string":"stale","new_string":"ready-v1"}`
253 editArgs2 := `{"path":"prompt.txt","old_string":"stale","new_string":"ready-v2"}`
254 editArgs3 := `{"path":"prompt.txt","old_string":"stale","new_string":"ready-v3"}`
255 readArgs := `{"path":"prompt.txt"}`
256 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
257 {toolCallChunk("e1", "edit_file", editArgs1), {Type: provider.ChunkDone}},
258 {toolCallChunk("r1", "read_file", readArgs), {Type: provider.ChunkDone}},
259 {toolCallChunk("e2", "edit_file", editArgs2), {Type: provider.ChunkDone}},
260 {toolCallChunk("r2", "read_file", readArgs), {Type: provider.ChunkDone}},
261 {toolCallChunk("e3", "edit_file", editArgs3), {Type: provider.ChunkDone}},
262 {{Type: provider.ChunkText, Text: "blocked"}, {Type: provider.ChunkDone}},
263 }}
264 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
265
266 if err := a.Run(context.Background(), "fix prompt.txt"); err != nil {
267 t.Fatalf("Run: %v", err)
268 }
269 if got := atomic.LoadInt32(&editCalls); got != 2 {
270 t.Fatalf("edit_file executed %d times, want 2 before the repeat guard blocks", got)
271 }
272 if got := atomic.LoadInt32(&readCalls); got != 2 {
273 t.Fatalf("read_file executed %d times, want 2", got)
274 }
275 last := lastToolResult(a.session, "edit_file")
276 for _, want := range []string{"[loop guard]", "already failed 2 times", "Re-reading alone"} {
277 if !strings.Contains(last, want) {
278 t.Fatalf("blocked stale edit result should mention %q, got %q", want, last)
279 }
280 }
281 }
282
283 func TestRepeatGuardRetainsStaleEditFailuresAcrossGoalScope(t *testing.T) {
284 var editCalls int32
285 reg := tool.NewRegistry()
286 reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
287 editArgs := `{"path":"prompt.txt","old_string":"stale","new_string":"ready"}`
288 readArgs := `{"path":"prompt.txt"}`
289 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
290 {toolCallChunk("e1", "edit_file", editArgs), {Type: provider.ChunkDone}},
291 {toolCallChunk("r1", "read_file", readArgs), {Type: provider.ChunkDone}},
292 {{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
293 {toolCallChunk("e2", "edit_file", editArgs), {Type: provider.ChunkDone}},
294 {toolCallChunk("r2", "read_file", readArgs), {Type: provider.ChunkDone}},
295 {{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
296 {toolCallChunk("e3", "edit_file", editArgs), {Type: provider.ChunkDone}},
297 {{Type: provider.ChunkText, Text: "[goal:blocked:stale edit]"}, {Type: provider.ChunkDone}},
298 }}
299 reg.Add(fakeTool{name: "read_file", readOnly: true})
300 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
301 ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{
302 ID: "goal-scope-1",
303 TaskText: "fix prompt.txt",
304 })
305
306 for i := 0; i < 3; i++ {
307 if err := a.Run(ctx, "continue goal"); err != nil {
308 t.Fatalf("Run %d: %v", i+1, err)
309 }
310 }
311 if got := atomic.LoadInt32(&editCalls); got != 2 {
312 t.Fatalf("edit_file executed %d times across one goal scope, want 2", got)
313 }
314 if last := lastToolResult(a.session, "edit_file"); !strings.Contains(last, "[loop guard]") {
315 t.Fatalf("third goal-scope edit should be blocked, got %q", last)
316 }
317 }
318
319 func TestRepeatGuardClearsOrdinaryWriteFailureAcrossGoalRuns(t *testing.T) {
320 var editCalls int32
321 reg := tool.NewRegistry()
322 reg.Add(previewSuccessFailWriterTool{name: "edit_file", calls: &editCalls})
323 editArgs := `{"path":"prompt.txt","old_string":"current","new_string":"ready"}`
324 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
325 {toolCallChunk("e1", "edit_file", editArgs), {Type: provider.ChunkDone}},
326 {{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
327 {toolCallChunk("e2", "edit_file", editArgs), {Type: provider.ChunkDone}},
328 {{Type: provider.ChunkText, Text: "[goal:continue]"}, {Type: provider.ChunkDone}},
329 {toolCallChunk("e3", "edit_file", editArgs), {Type: provider.ChunkDone}},
330 {{Type: provider.ChunkText, Text: "[goal:blocked:permission denied]"}, {Type: provider.ChunkDone}},
331 }}
332 a := New(prov, reg, NewSession(""), Options{}, event.Discard)
333 ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{
334 ID: "goal-scope-1",
335 TaskText: "fix prompt.txt",
336 })
337
338 for i := 0; i < 3; i++ {
339 if err := a.Run(ctx, "continue goal"); err != nil {
340 t.Fatalf("Run %d: %v", i+1, err)
341 }
342 }
343 if got := atomic.LoadInt32(&editCalls); got != 3 {
344 t.Fatalf("edit_file executed %d times across Goal Runs, want ordinary write failure retried each Run", got)
345 }
346 }
347
348 func TestRepeatGuardDoesNotUsePreviewToClearWriteFailure(t *testing.T) {
349 var editCalls int32
350 reg := tool.NewRegistry()
351 reg.Add(previewSuccessFailWriterTool{name: "edit_file", calls: &editCalls})
352 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
353 ctx := context.Background()
354 edit := provider.ToolCall{Name: "edit_file", Arguments: `{"path":"prompt.txt","old_string":"current","new_string":"ready"}`}
355
356 executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
357 executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
358 last := executeBatchOutputs(a, ctx, []provider.ToolCall{edit})[0]
359
360 if !strings.Contains(last, "[loop guard]") {
361 t.Fatalf("successful preview must not clear a repeated write failure, got %q", last)
362 }
363 for _, staleHint := range []string{"stale anchor", "Re-reading alone", "new old_string"} {
364 if strings.Contains(last, staleHint) {
365 t.Fatalf("ordinary write failure should not use stale-anchor guidance %q, got %q", staleHint, last)
366 }
367 }
368 if got := atomic.LoadInt32(&editCalls); got != 2 {
369 t.Fatalf("edit_file executed %d times, want write failure blocked before third execution", got)
370 }
371 }
372
373 func TestRepeatGuardKeepsStaleFailureAfterUnrelatedMutation(t *testing.T) {
374 var editCalls int32
375 reg := tool.NewRegistry()
376 reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
377 reg.Add(fakeTool{name: "write_file", readOnly: false})
378 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
379 ctx := context.Background()
380 edit := provider.ToolCall{Name: "edit_file", Arguments: `{"path":"prompt.txt","old_string":"stale","new_string":"ready"}`}
381
382 executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
383 executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
384 executeBatchOutputs(a, ctx, []provider.ToolCall{{
385 Name: "write_file", Arguments: `{"path":"other.txt","content":"unrelated"}`,
386 }})
387 last := executeBatchOutputs(a, ctx, []provider.ToolCall{edit})[0]
388
389 if !strings.Contains(last, "[loop guard]") {
390 t.Fatalf("unrelated mutation should not clear stale failure history, got %q", last)
391 }
392 if got := atomic.LoadInt32(&editCalls); got != 2 {
393 t.Fatalf("edit_file executed %d times, want unrelated mutation to preserve the guard", got)
394 }
395 }
396
397 func TestRepeatGuardKeepsStaleFailureAfterSameFileMutation(t *testing.T) {
398 dir := t.TempDir()
399 path := filepath.Join(dir, "prompt.txt")
400 if err := os.WriteFile(path, []byte("status=ready\n"), 0o600); err != nil {
401 t.Fatal(err)
402 }
403 reg := tool.NewRegistry()
404 for _, tl := range (builtin.Workspace{Dir: dir}).Tools("edit_file") {
405 reg.Add(tl)
406 }
407 a := New(nil, reg, NewSession(""), Options{WriteWorkspaceRoot: dir}, event.Discard)
408 ctx := context.Background()
409 stale := provider.ToolCall{
410 Name: "edit_file",
411 Arguments: `{"path":"prompt.txt","old_string":"status=stale","new_string":"status=fixed"}`,
412 }
413
414 executeBatchOutputs(a, ctx, []provider.ToolCall{stale})
415 executeBatchOutputs(a, ctx, []provider.ToolCall{stale})
416 executeBatchOutputs(a, ctx, []provider.ToolCall{{
417 Name: "edit_file",
418 Arguments: `{"path":"prompt.txt","old_string":"status=ready","new_string":"status=done"}`,
419 }})
420 last := executeBatchOutputs(a, ctx, []provider.ToolCall{stale})[0]
421
422 if !strings.Contains(last, "[loop guard]") {
423 t.Fatalf("same-file mutation must not renew a still-stale anchor budget, got %q", last)
424 }
425 data, err := os.ReadFile(path)
426 if err != nil {
427 t.Fatal(err)
428 }
429 if got := string(data); got != "status=done\n" {
430 t.Fatalf("file content = %q, want successful unrelated edit preserved", got)
431 }
432 }
433
434 func TestRepeatGuardNormalizesFailureTargetPaths(t *testing.T) {
435 var editCalls int32
436 dir := t.TempDir()
437 reg := tool.NewRegistry()
438 reg.Add(failingWriterTool{name: "edit_file", calls: &editCalls})
439 a := New(nil, reg, NewSession(""), Options{WriteWorkspaceRoot: dir}, event.Discard)
440 ctx := context.Background()
441 args := []string{
442 `{"path":"prompt.txt","old_string":"stale","new_string":"ready-v1"}`,
443 `{"path":"./prompt.txt","old_string":"stale","new_string":"ready-v2"}`,
444 `{"path":` + string(mustJSON(t, filepath.Join(dir, "prompt.txt"))) + `,"old_string":"stale","new_string":"ready-v3"}`,
445 }
446
447 executeBatchOutputs(a, ctx, []provider.ToolCall{{Name: "edit_file", Arguments: args[0]}})
448 executeBatchOutputs(a, ctx, []provider.ToolCall{{Name: "edit_file", Arguments: args[1]}})
449 last := executeBatchOutputs(a, ctx, []provider.ToolCall{{Name: "edit_file", Arguments: args[2]}})[0]
450
451 if !strings.Contains(last, "[loop guard]") {
452 t.Fatalf("path aliases should share one repeated-failure signature, got %q", last)
453 }
454 if got := atomic.LoadInt32(&editCalls); got != 2 {
455 t.Fatalf("edit_file executed %d times, want absolute-path retry blocked", got)
456 }
457 }
458
459 func TestRepeatGuardAllowsRetryAfterExternalTargetChange(t *testing.T) {
460 var editCalls int32
461 var valid atomic.Bool
462 reg := tool.NewRegistry()
463 reg.Add(stateAwareWriterTool{name: "edit_file", calls: &editCalls, valid: &valid})
464 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
465 ctx := context.Background()
466 edit := provider.ToolCall{Name: "edit_file", Arguments: `{"path":"prompt.txt","old_string":"stale","new_string":"ready"}`}
467
468 executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
469 executeBatchOutputs(a, ctx, []provider.ToolCall{edit})
470 valid.Store(true)
471 last := executeBatchOutputs(a, ctx, []provider.ToolCall{edit})[0]
472
473 if strings.Contains(last, "[loop guard]") {
474 t.Fatalf("changed target state should allow the retry, got %q", last)
475 }
476 if got := atomic.LoadInt32(&editCalls); got != 3 {
477 t.Fatalf("edit_file executed %d times, want retry after external target change", got)
478 }
479 }
480
480 lines GO