返回 DeepSeek-Reasonix
protocol_recovery.go
根目录 / internal / agent / protocol_recovery.go
1 package agent
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "maps"
12 "slices"
13
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 )
17
18 var ErrProtocolRecoveryUnavailable = errors.New("protocol recovery is unavailable or stale")
19
20 type protocolRecoveryKey struct{}
21 type protocolRecoveryRequest struct {
22 id string
23 record provider.ProtocolRecoveryRecord
24 ready, consumed bool
25 }
26
27 func WithProtocolRecovery(ctx context.Context, id string) context.Context {
28 return context.WithValue(ctx, protocolRecoveryKey{}, &protocolRecoveryRequest{id: id})
29 }
30
31 func protocolHistory(messages []provider.Message) []provider.Message {
32 // Host continuation text is not a new user task. Keep original human
33 // boundaries when validating a recovery after a pre-request admission error.
34 out := make([]provider.Message, 0, len(messages))
35 for _, m := range messages {
36 if m.Role == provider.RoleUser && m.Origin == provider.MessageOriginHost {
37 continue
38 }
39 out = append(out, m)
40 }
41 out = append([]provider.Message(nil), provider.ModelMessages(out)...)
42 for i := range out {
43 out[i].CreatedAt = 0
44 out[i].WorkDurationMs = 0
45 }
46 return out
47 }
48
49 // Evidence changes invalidate a pending action without renewing a consumed
50 // protocol budget or altering any provider-visible message bytes.
51 func protocolEvidenceDigest(messages []provider.Message) string {
52 type receipt struct {
53 ID, Name string
54 State provider.ToolRunState
55 }
56 var evidence []receipt
57 for _, m := range messages {
58 if !m.LocalOnly && m.Role == provider.RoleTool {
59 evidence = append(evidence, receipt{m.ToolCallID, m.Name, provider.ToolResultRunState(m)})
60 }
61 }
62 return protocolDigest(evidence)
63 }
64
65 func protocolDigest(v any) string {
66 b, _ := json.Marshal(v)
67 h := sha256.Sum256(b)
68 return hex.EncodeToString(h[:])
69 }
70
71 func (a *Agent) protocolRecoveryScope() string {
72 return protocolDigest([]string{a.SessionPath(), provider.MissingToolCallReasoningWarningFingerprint(a.svc.prov)})
73 }
74
75 func (a *Agent) latestProtocolRecord() (provider.ProtocolRecoveryRecord, bool) {
76 if a == nil || a.Session() == nil {
77 return provider.ProtocolRecoveryRecord{}, false
78 }
79 for _, m := range slices.Backward(a.Session().Snapshot()) {
80 if len(m.ProtocolRecovery) > 0 {
81 return provider.DecodeProtocolRecovery(m.ProtocolRecovery)
82 }
83 }
84 return provider.ProtocolRecoveryRecord{}, false
85 }
86
87 // PendingProtocolRecovery is read-only and safe for idle UI/history queries.
88 func (a *Agent) PendingProtocolRecovery() *provider.ProtocolRecoveryAction {
89 r, ok := a.latestProtocolRecord()
90 if !ok || r.State != "pending" || r.Scope != a.protocolRecoveryScope() {
91 return nil
92 }
93 snapshot := a.Session().Snapshot()
94 if protocolDigest(protocolHistory(snapshot)) != r.Fingerprint || (r.Evidence != "" && protocolEvidenceDigest(snapshot) != r.Evidence) {
95 return nil
96 }
97 return &provider.ProtocolRecoveryAction{ID: r.ID}
98 }
99
100 func (a *Agent) prepareProtocolRecovery(ctx context.Context) error {
101 request, _ := ctx.Value(protocolRecoveryKey{}).(*protocolRecoveryRequest)
102 if request == nil {
103 return nil
104 }
105 action := a.PendingProtocolRecovery()
106 if action == nil || request.id != action.ID || ctx.Err() != nil {
107 return ErrProtocolRecoveryUnavailable
108 }
109 r, _ := a.latestProtocolRecord()
110 request.record, request.ready = r, true
111 return nil
112 }
113
114 func (a *Agent) restoreProtocolProjection() {
115 r, ok := a.latestProtocolRecord()
116 if !ok || !r.Projected || r.State != "consumed" || r.Scope != a.protocolRecoveryScope() {
117 return
118 }
119 messages := protocolHistory(a.Session().Snapshot())
120 if r.Count <= 0 || r.Count > len(messages) || protocolDigest(messages[:r.Count]) != r.Fingerprint {
121 return
122 }
123 a.sess.reasoningReplayStrongProjection = r.Prefix
124 a.sess.reasoningReplayStrongProjectionAnchor = r.Anchor
125 }
126
127 func (a *Agent) protocolRecoverySpent() bool {
128 for _, m := range slices.Backward(a.Session().Snapshot()) {
129 if len(m.ProtocolRecovery) == 0 {
130 continue
131 }
132 var version struct {
133 Version int `json:"version"`
134 }
135 if json.Unmarshal(m.ProtocolRecovery, &version) != nil || version.Version != 1 {
136 return true
137 }
138 break
139 }
140 r, ok := a.latestProtocolRecord()
141 if !ok || r.State != "consumed" || r.Scope != a.protocolRecoveryScope() {
142 return false
143 }
144 messages := protocolHistory(a.Session().Snapshot())
145 if r.Count <= 0 || r.Count > len(messages) || protocolDigest(messages[:r.Count]) != r.Fingerprint {
146 return false
147 }
148 // A fresh assistant/tool round can introduce a genuinely different failure.
149 for _, m := range messages[r.Count:] {
150 if m.Role == provider.RoleAssistant && (len(m.ToolCalls) > 0 || len(m.ThinkingBlocks) > 0 || len(m.ResponsesItems) > 0 || m.ReasoningContent != "") {
151 return false
152 }
153 }
154 return true
155 }
156
157 func (a *Agent) saveProtocolRecord(r provider.ProtocolRecoveryRecord) error {
158 raw, err := json.Marshal(r)
159 if err != nil {
160 return err
161 }
162 message := a.Session().prepareProtocolRecord(r.ID, raw)
163 if recorder, ok := a.svc.sessionCheckpointer.(SessionMessageMutationRecorder); ok {
164 if err := recorder.RecordSessionMessageUpsert(context.Background(), "protocol-recovery", message); err != nil {
165 return fmt.Errorf("record protocol recovery: %w", err)
166 }
167 }
168 a.Session().applyProtocolRecord(message)
169 return event.EmitChecked(a.svc.sink, event.Event{Kind: event.Notice, RecoveryCheckpoint: true})
170 }
171
172 func (a *Agent) protocolRecord(frozen samplingRequest, state string) provider.ProtocolRecoveryRecord {
173 canonical := protocolHistory(a.Session().Snapshot())
174 prefix := len(frozen.req.Messages)
175 anchor := ""
176 if prefix > 0 {
177 anchor = reasoningReplayMessageFingerprint(frozen.req.Messages[prefix-1])
178 }
179 return provider.ProtocolRecoveryRecord{Evidence: protocolEvidenceDigest(a.Session().Snapshot()), Version: 1, ID: rand.Text(), State: state, Scope: a.protocolRecoveryScope(), Fingerprint: protocolDigest(canonical), Count: len(canonical), Prefix: prefix, Anchor: anchor, Run: a.protocolRunSeq.Load()}
180 }
181
182 func (a *Agent) offerProtocolRecovery(frozen samplingRequest, err error) error {
183 if !provider.IsOpaqueBadRequest(err) || a.protocolRecoverySpent() {
184 return nil
185 }
186 if _, changed := provider.ProjectReasoningStrippedMessages(a.svc.prov, frozen.req.Messages); !changed {
187 return nil
188 }
189 if len(frozen.req.Messages) == 0 {
190 return nil
191 }
192 return a.saveProtocolRecord(a.protocolRecord(frozen, "pending"))
193 }
194
195 // consumeManualProtocolRecovery runs after request preparation and immediately
196 // before any provider invocation. Failure to checkpoint prevents the request.
197 func (a *Agent) consumeManualProtocolRecovery(ctx context.Context, s *samplingRecoveryState) error {
198 r, _ := ctx.Value(protocolRecoveryKey{}).(*protocolRecoveryRequest)
199 if r == nil || r.consumed {
200 return nil
201 }
202 if !r.ready || ctx.Err() != nil {
203 return ErrProtocolRecoveryUnavailable
204 }
205 prefix := resolveReasoningReplayPrefix(s.frozen.req.Messages, r.record.Prefix, r.record.Anchor)
206 if prefix <= 0 {
207 return ErrProtocolRecoveryUnavailable
208 }
209 projected, changed := provider.ProjectReasoningStrippedMessagesPrefix(a.svc.prov, s.frozen.req.Messages, prefix)
210 if !changed {
211 return ErrProtocolRecoveryUnavailable
212 }
213 previous := s.frozen.req.Messages
214 s.frozen.req.Messages = a.replayRecoveryFacts(previous[:prefix], projected)
215 if err := a.applyAdmissionToRequest(&s.frozen.req); err != nil {
216 return err
217 }
218 r.record.State = "consumed"
219 r.record.Projected = true
220 if err := a.saveProtocolRecord(r.record); err != nil {
221 return fmt.Errorf("checkpoint protocol recovery: %w", err)
222 }
223 r.consumed = true
224 s.protocol = true
225 s.replay = reasoningReplayRecoveryBudget{retries: 1, cutoff: prefix, anchor: r.record.Anchor, persisted: true}
226 // Keep the repaired view for subsequent requests even if generation fails.
227 a.sess.reasoningReplayStrongProjection = prefix
228 a.sess.reasoningReplayStrongProjectionAnchor = r.record.Anchor
229 return nil
230 }
231
232 func (s *Session) storeProtocolRecord(id string, raw json.RawMessage) {
233 s.applyProtocolRecord(s.prepareProtocolRecord(id, raw))
234 }
235
236 // prepareProtocolRecord builds the exact stable message that will be committed
237 // without mutating the in-memory transcript. The event recorder can therefore
238 // accept the authoritative change before the legacy message cache is updated.
239 func (s *Session) prepareProtocolRecord(id string, raw json.RawMessage) provider.Message {
240 if s == nil {
241 return provider.Message{}
242 }
243 s.mu.RLock()
244 defer s.mu.RUnlock()
245 for i := range slices.Backward(s.Messages) {
246 if record, ok := provider.DecodeProtocolRecovery(s.Messages[i].ProtocolRecovery); ok && record.ID == id {
247 message := s.Messages[i]
248 var fields map[string]json.RawMessage
249 _ = json.Unmarshal(message.ProtocolRecovery, &fields)
250 var changes map[string]json.RawMessage
251 _ = json.Unmarshal(raw, &changes)
252 maps.Copy(fields, changes)
253 message.ProtocolRecovery, _ = json.Marshal(fields)
254 return message
255 }
256 }
257 return provider.Message{ID: NewMessageID(), Role: provider.RoleTool, Name: provider.LocalOnlyToolName, ToolCallID: provider.LocalOnlyToolID, LocalOnly: true, ProtocolRecovery: append(json.RawMessage(nil), raw...)}
258 }
259
260 func (s *Session) applyProtocolRecord(message provider.Message) {
261 if s == nil || message.ID == "" {
262 return
263 }
264 s.mu.Lock()
265 defer s.mu.Unlock()
266 for i := range slices.Backward(s.Messages) {
267 if s.Messages[i].ID == message.ID {
268 s.Messages[i] = message
269 s.version++
270 s.rewriteVersion++
271 return
272 }
273 }
274 s.Messages = append(s.Messages, message)
275 s.version++
276 }
277
278 // New authored input invalidates a pending button. Consumed repair accounting
279 // remains intact, so sending "continue" cannot renew the same incident budget.
280 func (s *Session) expireProtocolRecoveryLocked(added []provider.Message) {
281 if !slices.ContainsFunc(added, IsUserAuthoredTurnMessage) {
282 return
283 }
284 for i := range slices.Backward(s.Messages) {
285 raw := s.Messages[i].ProtocolRecovery
286 if len(raw) == 0 {
287 continue
288 }
289 r, ok := provider.DecodeProtocolRecovery(raw)
290 if ok && r.State == "pending" {
291 var fields map[string]json.RawMessage
292 if json.Unmarshal(raw, &fields) == nil {
293 fields["state"] = json.RawMessage(`"expired"`)
294 s.Messages[i].ProtocolRecovery, _ = json.Marshal(fields)
295 s.rewriteVersion++
296 }
297 }
298 return
299 }
300 }
301
301 lines GO