返回 DeepSeek-Reasonix
tool_recovery_records.go
根目录 / internal / agent / tool_recovery_records.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "fmt"
11 "slices"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 )
18
19 func recoveryDigest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
20
21 func (s *Session) toolRecoveryRecord(callID string) *provider.ToolCallRecord {
22 for _, m := range slices.Backward(s.Snapshot()) {
23 for _, c := range m.ToolCalls {
24 if c.ID == callID && c.Recovery != nil {
25 r := *c.Recovery
26 r.Arguments = append(json.RawMessage(nil), r.Arguments...)
27 return &r
28 }
29 }
30 }
31 return nil
32 }
33
34 // Metadata updates detach the call slice so concurrent snapshots remain immutable.
35 func (s *Session) setToolRecoveryRecord(id string, r provider.ToolCallRecord) bool {
36 s.mu.Lock()
37 defer s.mu.Unlock()
38 for i := range slices.Backward(s.Messages) {
39 for j, call := range s.Messages[i].ToolCalls {
40 if call.ID != id {
41 continue
42 }
43 if call.Recovery != nil && call.Recovery.Identity.AttemptID != r.Identity.AttemptID {
44 return false
45 }
46 calls := append([]provider.ToolCall(nil), s.Messages[i].ToolCalls...)
47 r.Arguments = append(json.RawMessage(nil), r.Arguments...)
48 calls[j].Recovery = &r
49 s.Messages[i].ToolCalls = calls
50 s.version++
51 s.recoveryMetadataVersion = s.version
52 return true
53 }
54 }
55 return false
56 }
57
58 func (a *Agent) beginToolRecovery(ctx context.Context, p *toolCallPlan) error {
59 if err := ctx.Err(); err != nil {
60 return err
61 }
62 var params any
63 decoder := json.NewDecoder(bytes.NewReader(p.permArgs))
64 decoder.UseNumber()
65 if err := decoder.Decode(&params); err != nil {
66 return err
67 }
68 canonical, err := json.Marshal(params)
69 if err != nil {
70 return err
71 }
72 attempt := make([]byte, 16)
73 if _, err := rand.Read(attempt); err != nil {
74 return err
75 }
76 msgs := a.sess.conversation.Snapshot()
77 identity := provider.ActionIdentity{CallID: p.call.ID, CanonicalTool: p.permName, ArgumentDigest: recoveryDigest(canonical), AttemptID: hex.EncodeToString(attempt)}
78 if len(msgs) > 0 {
79 identity.SessionID = msgs[0].ID
80 }
81 for _, m := range slices.Backward(msgs) {
82 if IsUserAuthoredTurnMessage(m) {
83 identity.TurnID = m.ID
84 break
85 }
86 }
87 if open, ok := a.sess.conversation.OpenTurn(); ok {
88 identity.TurnID = open.TurnID
89 }
90 // Keep a stable scope for historical display and idempotency diagnostics.
91 // Resource scopes do not restrict later tool admission.
92 identity.ResourceScope = "session:" + identity.SessionID
93 if verifier, ok := p.runTool.(tool.EffectVerifier); ok {
94 identity.ResourceScope = verifier.RecoveryScope()
95 if identity.ResourceScope == "" {
96 return fmt.Errorf("tool recovery sink identity unavailable")
97 }
98 }
99 keyInput := identity
100 keyInput.AttemptID = ""
101 keyJSON, _ := json.Marshal(keyInput)
102 r := provider.ToolCallRecord{Identity: identity, State: provider.ToolRunStarted, ReadOnly: p.readOnly, Arguments: append(json.RawMessage(nil), p.permArgs...), IdempotencyKey: recoveryDigest(keyJSON), StartedAt: time.Now().UnixMilli()}
103 p.cctx = tool.WithRecoveryIdempotencyKey(p.cctx, r.IdempotencyKey)
104 p.call.Recovery = &r
105 if a.sess.conversation.setToolRecoveryRecord(p.call.ID, r) {
106 if err := event.EmitChecked(a.svc.sink, event.Event{Kind: event.Notice, RecoveryCheckpoint: true}); err != nil {
107 r.State = provider.ToolRunNotStarted
108 a.sess.conversation.setToolRecoveryRecord(p.call.ID, r)
109 return err
110 }
111 }
112 if err := a.emitToolStarted(p.call); err != nil {
113 r.State = provider.ToolRunNotStarted
114 a.sess.conversation.setToolRecoveryRecord(p.call.ID, r)
115 return err
116 }
117 return nil
118 }
119
120 func (a *Agent) finishToolRecovery(call provider.ToolCall, out toolOutcome) {
121 r := a.sess.conversation.toolRecoveryRecord(call.ID)
122 if r == nil {
123 return
124 }
125 r.State = outcomeRunState(out)
126 if out.executed && out.errMsg != "" && r.State == provider.ToolRunCompleted {
127 r.State = provider.ToolRunFailed
128 }
129 // An explicit tool error proves failure, not absence of partial effects.
130 r.FinishedAt = time.Now().UnixMilli()
131 r.ResultDigest = recoveryDigest([]byte(out.output))
132 a.sess.conversation.setToolRecoveryRecord(call.ID, *r)
133 }
134
135 func unresolvedToolRecord(r provider.ToolCallRecord) bool {
136 if r.SupersededBy != "" {
137 return false
138 }
139 return r.State == provider.ToolRunStarted || r.State == provider.ToolRunRunning || r.State == provider.ToolRunUnknown || (r.State == provider.ToolRunFailed && !r.ReadOnly && r.EffectSummary == "effect_unknown")
140 }
141
142 // Rewriting model history cannot erase an unresolved external-effect fact. A
143 // local-only record survives compaction/rewind on the same session, but does
144 // not restrict later tool admission.
145 // PlanRetainedToolRecords applies the same retention rule as Session.Replace
146 // without changing the session. Callers assign stable IDs before committing.
147 func PlanRetainedToolRecords(previous, next []provider.Message) []provider.Message {
148 return retainUnresolvedToolRecords(previous, next)
149 }
150
151 func retainUnresolvedToolRecords(previous, next []provider.Message) []provider.Message {
152 seen := map[string]bool{}
153 for _, m := range next {
154 for _, c := range m.ToolCalls {
155 if c.Recovery != nil {
156 seen[c.Recovery.Identity.AttemptID] = true
157 }
158 }
159 }
160 for _, m := range previous {
161 for _, c := range m.ToolCalls {
162 if c.Recovery == nil || c.Recovery.ReadOnly || (!unresolvedToolRecord(*c.Recovery) && c.Recovery.State != provider.ToolRunUserConfirmed) || seen[c.Recovery.Identity.AttemptID] {
163 continue
164 }
165 seen[c.Recovery.Identity.AttemptID] = true
166 next = append(append([]provider.Message(nil), next...), provider.Message{Role: provider.RoleTool, LocalOnly: true, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, ToolCalls: []provider.ToolCall{c}})
167 }
168 }
169 return next
170 }
171
172 // PendingToolRecovery reads durable execution facts, independent of prompt-tail
173 // consumption. Records are detached before leaving the session boundary.
174 func (a *Agent) PendingToolRecovery() []provider.ToolCallRecord {
175 result := []provider.ToolCallRecord{}
176 if a == nil || a.sess.conversation == nil {
177 return result
178 }
179 seen := map[string]bool{}
180 for _, m := range slices.Backward(a.sess.conversation.Snapshot()) {
181 for _, call := range m.ToolCalls {
182 if call.Recovery == nil {
183 continue
184 }
185 r := *call.Recovery
186 id := r.Identity.AttemptID
187 if seen[id] {
188 continue
189 }
190 seen[id] = true
191 if !unresolvedToolRecord(r) {
192 continue
193 }
194 r.Arguments = append(json.RawMessage(nil), r.Arguments...)
195 result = append(result, r)
196 }
197 }
198 return result
199 }
200
200 lines GO