返回 DeepSeek-Reasonix
repeat_failure_guard.go
根目录 / internal / agent / repeat_failure_guard.go
1 package agent
2
3 import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8
9 "reasonix/internal/evidence"
10 "reasonix/internal/provider"
11 "reasonix/internal/tool"
12 )
13
14 // repeatFailureBreakThreshold is how many identical write-like failures are
15 // allowed before refusing the next attempt. Reads do not renew this budget
16 // because they cannot make unchanged stale write arguments valid.
17 const repeatFailureBreakThreshold = 2
18
19 func (a *Agent) repeatedFailureBlock(call provider.ToolCall, t tool.Tool) (string, bool) {
20 sig, _, ok := a.repeatFailureSignature(call, t)
21 if !ok || a.repeatFailureCounts == nil {
22 return "", false
23 }
24 record, ok := a.repeatFailureCounts[sig]
25 if !ok || record.count < repeatFailureBreakThreshold {
26 return "", false
27 }
28 if repeatFailurePreviewRechecksState(call.Name, record.errClass) {
29 if previewer, ok := t.(tool.Previewer); ok {
30 _, err := previewer.Preview(json.RawMessage(call.Arguments))
31 if err == nil || repeatFailureErrorClass(call.Name, err) != record.errClass {
32 delete(a.repeatFailureCounts, sig)
33 return "", false
34 }
35 }
36 }
37 if record.stateRecheck {
38 return fmt.Sprintf(
39 "blocked: [loop guard] %q has already failed %d times while the same write intent remained invalid with the same failure class. Re-reading alone cannot make the same stale anchor succeed. Rebuild the edit from the current file contents with a new old_string, use multi_edit for related changes, or explain the blocker in your final answer.",
40 call.Name, record.count), true
41 }
42 return fmt.Sprintf(
43 "blocked: [loop guard] %q has already failed %d times with the same write intent and failure class. Change the write conditions or use a different approach instead of retrying the same call, or explain the blocker in your final answer.",
44 call.Name, record.count), true
45 }
46
47 func (a *Agent) recordRepeatFailure(call provider.ToolCall, t tool.Tool, execErr error) {
48 sig, paths, ok := a.repeatFailureSignature(call, t)
49 if !ok {
50 return
51 }
52 if a.repeatFailureCounts == nil {
53 a.repeatFailureCounts = make(map[string]repeatFailureRecord)
54 }
55 errClass := repeatFailureErrorClass(call.Name, execErr)
56 record := a.repeatFailureCounts[sig]
57 if record.errClass != errClass {
58 record = repeatFailureRecord{
59 errClass: errClass,
60 paths: paths,
61 stateRecheck: repeatFailurePreviewRechecksState(call.Name, errClass),
62 }
63 }
64 record.count++
65 a.repeatFailureCounts[sig] = record
66 }
67
68 func (a *Agent) repeatFailureSignature(call provider.ToolCall, t tool.Tool) (string, []string, bool) {
69 if t.ReadOnly() {
70 return "", nil, false
71 }
72
73 var semantic any
74 switch call.Name {
75 case "edit_file":
76 var p struct {
77 Path string `json:"path"`
78 OldString string `json:"old_string"`
79 }
80 if err := json.Unmarshal([]byte(call.Arguments), &p); err != nil {
81 return "", nil, false
82 }
83 paths := a.normalizeRepeatFailurePaths([]string{p.Path})
84 if len(paths) > 0 {
85 p.Path = paths[0]
86 }
87 semantic = p
88 case "multi_edit":
89 var p struct {
90 Path string `json:"path"`
91 Edits []struct {
92 OldString string `json:"old_string"`
93 ReplaceAll bool `json:"replace_all,omitempty"`
94 } `json:"edits"`
95 }
96 if err := json.Unmarshal([]byte(call.Arguments), &p); err != nil {
97 return "", nil, false
98 }
99 paths := a.normalizeRepeatFailurePaths([]string{p.Path})
100 if len(paths) > 0 {
101 p.Path = paths[0]
102 }
103 semantic = p
104 default:
105 sig, ok := repeatSuccessSignature(call, t)
106 if !ok {
107 return "", nil, false
108 }
109 rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), false, t.ReadOnly())
110 return sig, a.normalizeRepeatFailurePaths(rec.Paths), true
111 }
112
113 encoded, err := json.Marshal(semantic)
114 if err != nil {
115 return "", nil, false
116 }
117 rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), false, t.ReadOnly())
118 return call.Name + "\x00" + string(encoded), a.normalizeRepeatFailurePaths(rec.Paths), true
119 }
120
121 func repeatFailureErrorClass(name string, execErr error) string {
122 if execErr == nil {
123 return ""
124 }
125 msg := firstLine(execErr.Error())
126 switch name {
127 case "edit_file", "multi_edit":
128 switch {
129 case strings.Contains(msg, "old_string not found"):
130 return "old_string_not_found"
131 case strings.Contains(msg, "old_string is not unique"):
132 return "old_string_not_unique"
133 }
134 }
135 return msg
136 }
137
138 func repeatFailurePreviewRechecksState(name, errClass string) bool {
139 switch name {
140 case "edit_file", "multi_edit":
141 return errClass == "old_string_not_found" ||
142 errClass == "old_string_not_unique"
143 default:
144 return false
145 }
146 }
147
148 func (a *Agent) clearRepeatFailuresAfterMutation(toolName string, args json.RawMessage, readOnly bool) {
149 if len(a.repeatFailureCounts) == 0 {
150 return
151 }
152 rec := evidence.ReceiptFromToolCall(toolName, args, true, readOnly)
153 mutatedPaths := a.normalizeRepeatFailurePaths(rec.Paths)
154 if len(mutatedPaths) == 0 {
155 for sig, failure := range a.repeatFailureCounts {
156 // Anchor errors have an exact, side-effect-free state check. Keep
157 // their history until Preview shows that the old anchor works again.
158 if !failure.stateRecheck {
159 delete(a.repeatFailureCounts, sig)
160 }
161 }
162 return
163 }
164 for sig, failure := range a.repeatFailureCounts {
165 // A different edit to the same file does not make this old anchor valid.
166 // repeatedFailureBlock rechecks the actual call through Preview.
167 if !failure.stateRecheck && repeatFailurePathsOverlap(failure.paths, mutatedPaths) {
168 delete(a.repeatFailureCounts, sig)
169 }
170 }
171 }
172
173 func (a *Agent) normalizeRepeatFailurePaths(paths []string) []string {
174 if len(paths) == 0 {
175 return nil
176 }
177 normalized := make([]string, 0, len(paths))
178 seen := make(map[string]bool, len(paths))
179 for _, raw := range paths {
180 path := strings.TrimSpace(raw)
181 if path == "" {
182 continue
183 }
184 if !filepath.IsAbs(path) && a.writeWorkspaceRoot != "" {
185 path = filepath.Join(a.writeWorkspaceRoot, path)
186 } else if !filepath.IsAbs(path) {
187 if absolute, err := filepath.Abs(path); err == nil {
188 path = absolute
189 }
190 }
191 path = foldPathKey(filepath.Clean(path))
192 if seen[path] {
193 continue
194 }
195 seen[path] = true
196 normalized = append(normalized, path)
197 }
198 return normalized
199 }
200
201 func repeatFailurePathsOverlap(left, right []string) bool {
202 for _, a := range left {
203 for _, b := range right {
204 if pathWithinFold(a, b) || pathWithinFold(b, a) {
205 return true
206 }
207 }
208 }
209 return false
210 }
211
211 lines GO