返回 DeepSeek-Reasonix
reasoning_warn_state.go
根目录 / internal / agent / reasoning_warn_state.go
1 package agent
2
3 // reasoning_warn_state.go rate-limits missing tool-call thinking recovery
4 // attempts across sessions and processes (#7059). Legacy warning-oriented Go
5 // names and JSON fields intentionally remain unchanged for upgrade and
6 // cross-version compatibility.
7 //
8 // DeepSeek's thinking-mode contract requires provider-issued thinking content
9 // to accompany tool calls and be replayed on later requests. Missing content is
10 // therefore a compatibility incident, not a permanent provider characteristic.
11 // State is keyed by an opaque configuration fingerprint, expires after a bounded
12 // cooldown, and is cleared after three consecutive healthy tool-call turns so
13 // a future isolated regression gets one fresh silent retry without flapping.
14
15 import (
16 "context"
17 "encoding/json"
18 "math"
19 "os"
20 "path/filepath"
21 "runtime"
22 "sort"
23 "strings"
24 "sync"
25 "time"
26
27 "reasonix/internal/filelock"
28 "reasonix/internal/fileutil"
29 )
30
31 const missingReasoningWarnStateFilename = "tool-call-reasoning-warning.json"
32
33 const (
34 missingReasoningWarnStateLockFilename = "tool-call-reasoning-warning.lock"
35 missingReasoningWarnStateVersion = 2
36 missingReasoningWarnStateCooldown = 24 * time.Hour
37 missingReasoningWarnStateMaxIncidents = 256
38 missingReasoningHealthyResolveStreak = 3
39 // Recovery bookkeeping must never make a turn wait indefinitely behind
40 // another process. On contention or I/O failure, callers fail open and allow
41 // the bounded retry rather than silently losing self-healing.
42 missingReasoningWarnStateLockTimeout = 200 * time.Millisecond
43 )
44
45 type missingReasoningIncident struct {
46 Fingerprint string `json:"fingerprint"`
47 WarnedAtUnixMs int64 `json:"warnedAtUnixMs,omitempty"`
48 LastMissingUnixMs int64 `json:"lastMissingAtUnixMs,omitempty"`
49 LastMissingUnixNano int64 `json:"lastMissingAtUnixNano,omitempty"`
50 LastResolvedAtUnixNano int64 `json:"lastResolvedAtUnixNano,omitempty"`
51 // These optional fields extend the v2 document without changing its version.
52 // Older builds ignore them and may drop an in-progress streak on write, while
53 // both generations continue to share the same active-incident boundary.
54 ResolveStreak int `json:"resolveStreak,omitempty"`
55 LastHealthyAtUnixNano int64 `json:"lastHealthyAtUnixNano,omitempty"`
56 }
57
58 type missingReasoningWarnDocument struct {
59 Version int `json:"version"`
60 Incidents []missingReasoningIncident `json:"incidents,omitempty"`
61 // Providers reads the unreleased v1 preview schema. Provider names cannot
62 // safely identify endpoint/model/protocol changes and have no timestamp, so
63 // they deliberately re-arm once and are omitted on the next v2 write.
64 Providers []string `json:"providers,omitempty"`
65 }
66
67 type missingReasoningWarnState struct {
68 dir string
69 }
70
71 // missingReasoningWarnClaimFlights coalesces concurrent observations of the
72 // same incident inside one process. filelock.Acquire intentionally has a short
73 // deadline so recovery bookkeeping cannot stall a turn behind another process. On a
74 // slower filesystem (notably Windows CI), several local callers can otherwise
75 // exhaust that deadline while queued behind the first atomic write and each
76 // take the fail-open path, producing duplicate recovery requests.
77 //
78 // Followers update the latest observation timestamp and let the leader persist
79 // it before the flight is removed. This preserves resolveAt's stale-health
80 // ordering guarantee instead of merely suppressing duplicate return values.
81 type missingReasoningWarnClaimFlight struct {
82 latestObservedAt time.Time
83 }
84
85 var missingReasoningWarnClaimFlights = struct {
86 sync.Mutex
87 flights map[string]*missingReasoningWarnClaimFlight
88 }{flights: map[string]*missingReasoningWarnClaimFlight{}}
89
90 // missingReasoningWarnProcessLocks serializes transactions for one state file
91 // before they enter filelock.Acquire. The file lock's short deadline is meant
92 // to bound waits on another process, not make distinct local incidents lose
93 // persistence while queued behind a slower Windows atomic write. Reasonix uses
94 // one state path per home, so retaining these few mutexes for the process
95 // lifetime is bounded in normal operation.
96 var missingReasoningWarnProcessLocks sync.Map
97
98 func newMissingReasoningWarnState(dir string) *missingReasoningWarnState {
99 return &missingReasoningWarnState{dir: strings.TrimSpace(dir)}
100 }
101
102 func (s *missingReasoningWarnState) path() string {
103 return filepath.Join(s.dir, missingReasoningWarnStateFilename)
104 }
105
106 func (s *missingReasoningWarnState) lockPath() string {
107 return filepath.Join(s.dir, missingReasoningWarnStateLockFilename)
108 }
109
110 func (s *missingReasoningWarnState) processLockKey() string {
111 path, err := filepath.Abs(s.path())
112 if err != nil {
113 path = s.path()
114 }
115 path = filepath.Clean(path)
116 if runtime.GOOS == "windows" {
117 path = strings.ToLower(filepath.ToSlash(path))
118 }
119 return path
120 }
121
122 func (s *missingReasoningWarnState) processLock() *sync.Mutex {
123 lock, _ := missingReasoningWarnProcessLocks.LoadOrStore(s.processLockKey(), &sync.Mutex{})
124 return lock.(*sync.Mutex)
125 }
126
127 func (s *missingReasoningWarnState) claimFlightKey(fingerprint string) string {
128 return s.processLockKey() + "\x00" + fingerprint
129 }
130
131 func validMissingReasoningFingerprint(fingerprint string) bool {
132 if len(fingerprint) != 64 {
133 return false
134 }
135 for _, r := range fingerprint {
136 if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
137 return false
138 }
139 }
140 return true
141 }
142
143 func missingReasoningUnixNanoFromMillis(unixMs int64) (int64, bool) {
144 if unixMs <= 0 || unixMs > math.MaxInt64/int64(time.Millisecond) {
145 return 0, false
146 }
147 return unixMs * int64(time.Millisecond), true
148 }
149
150 func normalizeMissingReasoningIncident(incident missingReasoningIncident, now time.Time) (missingReasoningIncident, bool) {
151 incident.Fingerprint = strings.TrimSpace(incident.Fingerprint)
152 if !validMissingReasoningFingerprint(incident.Fingerprint) ||
153 incident.LastMissingUnixNano < 0 || incident.LastResolvedAtUnixNano < 0 ||
154 incident.LastHealthyAtUnixNano < 0 || incident.ResolveStreak < 0 ||
155 incident.ResolveStreak >= missingReasoningHealthyResolveStreak {
156 return missingReasoningIncident{}, false
157 }
158
159 warnedAtUnixNano := int64(0)
160 if incident.WarnedAtUnixMs != 0 {
161 var ok bool
162 warnedAtUnixNano, ok = missingReasoningUnixNanoFromMillis(incident.WarnedAtUnixMs)
163 if !ok {
164 return missingReasoningIncident{}, false
165 }
166 }
167 if incident.LastMissingUnixMs != 0 {
168 lastMissingFromMillis, ok := missingReasoningUnixNanoFromMillis(incident.LastMissingUnixMs)
169 if !ok {
170 return missingReasoningIncident{}, false
171 }
172 if incident.LastMissingUnixNano == 0 {
173 incident.LastMissingUnixNano = lastMissingFromMillis
174 } else if incident.LastMissingUnixNano/int64(time.Millisecond) != incident.LastMissingUnixMs {
175 return missingReasoningIncident{}, false
176 }
177 } else if incident.LastMissingUnixNano != 0 {
178 return missingReasoningIncident{}, false
179 }
180
181 nowUnixNano := now.UnixNano()
182 if warnedAtUnixNano > nowUnixNano || incident.LastMissingUnixNano > nowUnixNano ||
183 incident.LastResolvedAtUnixNano > nowUnixNano || incident.LastHealthyAtUnixNano > nowUnixNano {
184 return missingReasoningIncident{}, false
185 }
186 if incident.LastMissingUnixNano > incident.LastResolvedAtUnixNano {
187 if warnedAtUnixNano == 0 || incident.LastMissingUnixNano < warnedAtUnixNano {
188 return missingReasoningIncident{}, false
189 }
190 age := now.Sub(time.UnixMilli(incident.WarnedAtUnixMs))
191 if age < 0 || age >= missingReasoningWarnStateCooldown {
192 return missingReasoningIncident{}, false
193 }
194 if incident.ResolveStreak > 0 {
195 if incident.LastHealthyAtUnixNano <= incident.LastMissingUnixNano ||
196 incident.LastHealthyAtUnixNano <= incident.LastResolvedAtUnixNano {
197 return missingReasoningIncident{}, false
198 }
199 } else if incident.LastHealthyAtUnixNano != 0 {
200 return missingReasoningIncident{}, false
201 }
202 return incident, true
203 }
204 if incident.LastResolvedAtUnixNano <= 0 || incident.ResolveStreak != 0 ||
205 incident.LastHealthyAtUnixNano > incident.LastResolvedAtUnixNano {
206 return missingReasoningIncident{}, false
207 }
208 resolvedAt := time.Unix(0, incident.LastResolvedAtUnixNano)
209 age := now.Sub(resolvedAt)
210 if age < 0 || age >= missingReasoningWarnStateCooldown {
211 return missingReasoningIncident{}, false
212 }
213 return incident, true
214 }
215
216 func (incident missingReasoningIncident) lastEventUnixNano() int64 {
217 lastEvent := incident.LastMissingUnixNano
218 if incident.LastResolvedAtUnixNano > lastEvent {
219 lastEvent = incident.LastResolvedAtUnixNano
220 }
221 if incident.LastHealthyAtUnixNano > lastEvent {
222 lastEvent = incident.LastHealthyAtUnixNano
223 }
224 return lastEvent
225 }
226
227 // load returns only current v2 incidents and resolution watermarks. Missing,
228 // corrupt, legacy, expired, or future-dated entries are treated as absent and
229 // self-heal on the next write. Read failures other than a missing file remain
230 // visible to the transaction so it cannot overwrite state from a partial read.
231 func (s *missingReasoningWarnState) load(now time.Time) (map[string]missingReasoningIncident, error) {
232 incidents := map[string]missingReasoningIncident{}
233 b, err := os.ReadFile(s.path())
234 if err != nil {
235 if os.IsNotExist(err) {
236 return incidents, nil
237 }
238 return nil, err
239 }
240 var doc missingReasoningWarnDocument
241 if json.Unmarshal(b, &doc) != nil || doc.Version != missingReasoningWarnStateVersion {
242 return incidents, nil
243 }
244 for _, rawIncident := range doc.Incidents {
245 incident, ok := normalizeMissingReasoningIncident(rawIncident, now)
246 if !ok {
247 continue
248 }
249 if previous, exists := incidents[incident.Fingerprint]; !exists || previous.lastEventUnixNano() < incident.lastEventUnixNano() {
250 incidents[incident.Fingerprint] = incident
251 }
252 }
253 return incidents, nil
254 }
255
256 func (s *missingReasoningWarnState) save(incidents map[string]missingReasoningIncident) error {
257 ordered := make([]missingReasoningIncident, 0, len(incidents))
258 for _, incident := range incidents {
259 ordered = append(ordered, incident)
260 }
261 if len(ordered) > missingReasoningWarnStateMaxIncidents {
262 sort.Slice(ordered, func(i, j int) bool {
263 return ordered[i].lastEventUnixNano() > ordered[j].lastEventUnixNano()
264 })
265 ordered = ordered[:missingReasoningWarnStateMaxIncidents]
266 }
267 sort.Slice(ordered, func(i, j int) bool {
268 return ordered[i].Fingerprint < ordered[j].Fingerprint
269 })
270 b, err := json.Marshal(missingReasoningWarnDocument{
271 Version: missingReasoningWarnStateVersion,
272 Incidents: ordered,
273 })
274 if err != nil {
275 return err
276 }
277 return fileutil.AtomicWriteFile(s.path(), b, 0o600)
278 }
279
280 func (s *missingReasoningWarnState) acquire() (func(), error) {
281 if err := os.MkdirAll(s.dir, 0o700); err != nil {
282 return nil, err
283 }
284 ctx, cancel := context.WithTimeout(context.Background(), missingReasoningWarnStateLockTimeout)
285 release, err := filelock.Acquire(ctx, s.lockPath())
286 if err != nil {
287 cancel()
288 return nil, err
289 }
290 return func() {
291 release()
292 cancel()
293 }, nil
294 }
295
296 func normalizeMissingReasoningObservedAt(observedAt time.Time) time.Time {
297 if observedAt.IsZero() {
298 return time.Now()
299 }
300 return observedAt
301 }
302
303 func missingReasoningTransactionNow(observedAt time.Time) time.Time {
304 now := time.Now()
305 if observedAt.After(now) {
306 return observedAt
307 }
308 return now
309 }
310
311 // persistClaimAt performs one cross-process read-check-write transaction.
312 // Persistence failure returns true so recovery fails open rather than being
313 // disabled by local bookkeeping trouble.
314 func (s *missingReasoningWarnState) persistClaimAt(fingerprint string, observedAt time.Time) bool {
315 processLock := s.processLock()
316 processLock.Lock()
317 defer processLock.Unlock()
318
319 release, err := s.acquire()
320 if err != nil {
321 return true
322 }
323 defer release()
324
325 incidents, err := s.load(missingReasoningTransactionNow(observedAt))
326 if err != nil {
327 return true
328 }
329 incident, exists := incidents[fingerprint]
330 observedAtUnixNano := observedAt.UnixNano()
331 if exists && (observedAtUnixNano <= incident.LastResolvedAtUnixNano ||
332 observedAtUnixNano <= incident.LastHealthyAtUnixNano) {
333 return false
334 }
335 activeIncident := exists && incident.LastMissingUnixNano > incident.LastResolvedAtUnixNano
336 shouldRetry := !activeIncident
337 if shouldRetry {
338 lastResolvedAtUnixNano := incident.LastResolvedAtUnixNano
339 incident = missingReasoningIncident{
340 Fingerprint: fingerprint,
341 WarnedAtUnixMs: observedAt.UnixMilli(),
342 LastResolvedAtUnixNano: lastResolvedAtUnixNano,
343 }
344 }
345 if incident.LastMissingUnixNano < observedAtUnixNano {
346 incident.LastMissingUnixMs = observedAt.UnixMilli()
347 incident.LastMissingUnixNano = observedAtUnixNano
348 incident.ResolveStreak = 0
349 incident.LastHealthyAtUnixNano = 0
350 }
351 incidents[fingerprint] = incident
352 if err := s.save(incidents); err != nil {
353 return true
354 }
355 return shouldRetry
356 }
357
358 // claimAt records the latest missing-reasoning observation and reports whether
359 // this active incident should receive its one recovery retry. Concurrent
360 // observations of the same incident in this process share one leader; the file
361 // lock covers the leader's read-check-write transactions against other
362 // processes sharing the Reasonix home.
363 func (s *missingReasoningWarnState) claimAt(fingerprint string, observedAt time.Time) bool {
364 fingerprint = strings.TrimSpace(fingerprint)
365 if s == nil || s.dir == "" || !validMissingReasoningFingerprint(fingerprint) {
366 return true
367 }
368 observedAt = normalizeMissingReasoningObservedAt(observedAt)
369 key := s.claimFlightKey(fingerprint)
370
371 missingReasoningWarnClaimFlights.Lock()
372 if flight := missingReasoningWarnClaimFlights.flights[key]; flight != nil {
373 if observedAt.After(flight.latestObservedAt) {
374 flight.latestObservedAt = observedAt
375 }
376 missingReasoningWarnClaimFlights.Unlock()
377 return false
378 }
379 flight := &missingReasoningWarnClaimFlight{latestObservedAt: observedAt}
380 missingReasoningWarnClaimFlights.flights[key] = flight
381 missingReasoningWarnClaimFlights.Unlock()
382
383 processedAt := time.Time{}
384 shouldRetry := false
385 for {
386 missingReasoningWarnClaimFlights.Lock()
387 nextObservedAt := flight.latestObservedAt
388 missingReasoningWarnClaimFlights.Unlock()
389
390 if nextObservedAt.After(processedAt) {
391 shouldRetry = s.persistClaimAt(fingerprint, nextObservedAt) || shouldRetry
392 processedAt = nextObservedAt
393 }
394
395 missingReasoningWarnClaimFlights.Lock()
396 if !flight.latestObservedAt.After(processedAt) {
397 delete(missingReasoningWarnClaimFlights.flights, key)
398 missingReasoningWarnClaimFlights.Unlock()
399 return shouldRetry
400 }
401 missingReasoningWarnClaimFlights.Unlock()
402 }
403 }
404
405 func (s *missingReasoningWarnState) claim(fingerprint string) bool {
406 return s.claimAt(fingerprint, time.Now())
407 }
408
409 type missingReasoningResolveResult struct {
410 Recorded bool
411 Resolved bool
412 }
413
414 // resolveAt records one healthy tool-call turn. Three consecutive healthy
415 // observations resolve the incident; another missing observation resets the
416 // streak. The healthy and resolved watermarks prevent events that happened
417 // earlier but acquired the cross-process lock later from changing newer state.
418 func (s *missingReasoningWarnState) resolveAt(fingerprint string, observedAt time.Time) missingReasoningResolveResult {
419 fingerprint = strings.TrimSpace(fingerprint)
420 if s == nil || s.dir == "" || !validMissingReasoningFingerprint(fingerprint) {
421 return missingReasoningResolveResult{}
422 }
423 observedAt = normalizeMissingReasoningObservedAt(observedAt)
424 processLock := s.processLock()
425 processLock.Lock()
426 defer processLock.Unlock()
427
428 release, err := s.acquire()
429 if err != nil {
430 return missingReasoningResolveResult{}
431 }
432 defer release()
433
434 incidents, err := s.load(missingReasoningTransactionNow(observedAt))
435 if err != nil {
436 return missingReasoningResolveResult{}
437 }
438 incident, exists := incidents[fingerprint]
439 observedAtUnixNano := observedAt.UnixNano()
440 if !exists || incident.LastMissingUnixNano <= incident.LastResolvedAtUnixNano {
441 return missingReasoningResolveResult{Recorded: true, Resolved: true}
442 }
443 if incident.LastMissingUnixNano >= observedAtUnixNano || incident.LastHealthyAtUnixNano >= observedAtUnixNano {
444 return missingReasoningResolveResult{Recorded: true}
445 }
446 incident.ResolveStreak++
447 incident.LastHealthyAtUnixNano = observedAtUnixNano
448 resolved := incident.ResolveStreak >= missingReasoningHealthyResolveStreak
449 if resolved {
450 incident.ResolveStreak = 0
451 incident.LastResolvedAtUnixNano = observedAtUnixNano
452 }
453 incidents[fingerprint] = incident
454 if s.save(incidents) != nil {
455 return missingReasoningResolveResult{}
456 }
457 return missingReasoningResolveResult{Recorded: true, Resolved: resolved}
458 }
459
459 lines GO