返回 DeepSeek-Reasonix
reasoning_replay.go
根目录 / internal / agent / reasoning_replay.go
1 package agent
2
3 import (
4 "context"
5 "slices"
6 "strings"
7
8 "reasonix/internal/event"
9 "reasonix/internal/provider"
10 )
11
12 func (a *Agent) preserveRawReasoning(reasoning, signature, reasoningID, reasoningStatus string, calls []provider.ToolCall, searches []provider.ServerSearchCall) bool {
13 if signature != "" || reasoningID != "" || reasoningStatus != "" {
14 return true
15 }
16 return provider.RequiresAssistantReasoningReplay(a.svc.prov, provider.Message{
17 Role: provider.RoleAssistant, ReasoningContent: reasoning, ToolCalls: calls, ServerSearch: searches,
18 })
19 }
20
21 // reasoningReplayMessageFingerprint identifies the last provider-visible
22 // message at the original repair boundary. It deliberately ignores durable UI fields,
23 // matching the same wire-visible fields used by the context projection hash.
24 func reasoningReplayMessageFingerprint(message provider.Message) string {
25 return providerVisibleFingerprint(provider.ModelMessages([]provider.Message{message}))
26 }
27
28 // resolveReasoningReplayPrefix maps the repaired provider prefix back onto a
29 // later canonical snapshot. Strong repair can remove old assistant/tool
30 // messages, so a raw message count alone can point into a different old turn.
31 func resolveReasoningReplayPrefix(msgs []provider.Message, hint int, anchor string) int {
32 if hint <= 0 || hint > len(msgs) {
33 return 0
34 }
35 if anchor == "" {
36 return hint
37 }
38 // Removed messages only make the canonical location move forward. Prefer
39 // the first matching anchor at/after the old provider-visible count; this
40 // also avoids selecting an earlier duplicate user message.
41 for i, message := range msgs {
42 if i+1 >= hint && reasoningReplayMessageFingerprint(message) == anchor {
43 return i + 1
44 }
45 }
46 return 0
47 }
48
49 func (a *Agent) emitReasoningReplayAttemptOutcome(id string, attempt int, err error) {
50 if err != nil {
51 a.emitStreamAttempt(id, event.StreamAttemptDiscard, attempt, "reasoning_replay", err)
52 return
53 }
54 a.emitStreamAttempt(id, event.StreamAttemptCommit, attempt, "", nil)
55 }
56
57 func (a *Agent) reasoningReplayIssue(result streamedTurn) ReasoningReplayFailure {
58 decision := provider.DecideReasoningReplay(a.svc.prov, result.assistantMessage(), result.reasoningComplete)
59 if decision == provider.ReplayDirect || decision == provider.ReplayCompatible {
60 return ""
61 }
62 if result.reasoningState == provider.ReasoningIncomplete || result.reasoningStatus == "in_progress" || result.reasoningStatus == "incomplete" {
63 return ReasoningReplayIncomplete
64 }
65 if !result.reasoningComplete || result.reasoningState == provider.ReasoningTruncated {
66 return ReasoningReplayOverflow
67 }
68 if decision == provider.ReplayReject {
69 return ReasoningReplayIncomplete
70 }
71 if !provider.HasReplayableReasoning(a.svc.prov, result.assistantMessage()) {
72 return ReasoningReplayMissing
73 }
74 return ""
75 }
76
77 // finishReasoningReplayOverflow terminates an attempt whose required reasoning
78 // was truncated by the client limit: audit, finalize usage, and hand the turn
79 // to the unreplayable-reasoning policy.
80 func (a *Agent) finishReasoningReplayOverflow(result streamedTurn, sink *deferredStreamSink, issue ReasoningReplayFailure, billable *provider.Usage, attemptID string, attempt int) streamedTurn {
81 if issue == ReasoningReplayOverflow {
82 event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryReasoningOverflowDetected})
83 }
84 result.usage = finalizeSamplingUsage(billable, result.usage)
85 terminal := a.finishUnreplayableReasoning(result, sink, issue)
86 if terminal.err != nil {
87 a.emitReasoningReplayAttemptOutcome(attemptID, attempt, terminal.err)
88 } else {
89 terminal.settledAttemptID, terminal.settledAttempt = attemptID, attempt
90 }
91 return terminal
92 }
93
94 func (a *Agent) finishUnreplayableReasoning(result streamedTurn, sink *deferredStreamSink, issue ReasoningReplayFailure) streamedTurn {
95 if issue == "" {
96 sink.Flush()
97 return result
98 }
99 // Empty can replace reasoning the provider never emitted, never reasoning
100 // truncated by the client limit: preserved-thinking protocols require the
101 // returned content to remain complete and unchanged.
102 allowsEmptyReasoning := issue == ReasoningReplayMissing && provider.AllowsEmptyReasoningFallback(a.svc.prov)
103 if len(result.calls) > 0 && !allowsEmptyReasoning {
104 sink.Discard()
105 event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryClientToolRejected})
106 result.err = &ReasoningReplayError{Kind: issue}
107 return result
108 }
109 if len(result.serverSearch) > 0 && !allowsEmptyReasoning {
110 if strings.TrimSpace(result.text) == "" {
111 sink.Discard()
112 result.err = &ReasoningReplayError{Kind: issue}
113 return result
114 }
115 // Preserve the answer and search cards locally. The provider projection
116 // removes these unreplayable search blocks on later requests.
117 result.reasoning = ""
118 result.signature = ""
119 result.reasoningID = ""
120 result.reasoningStatus = ""
121 result.reasoningComplete = true
122 sink.Flush()
123 event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryServerSearchSalvaged})
124 return result
125 }
126 if provider.RequiresReasoningRoundTrip(a.svc.prov) && !allowsEmptyReasoning {
127 sink.Discard()
128 result.err = &ReasoningReplayError{Kind: issue}
129 return result
130 }
131 // OpenAI-style DeepSeek protocols deliberately serialize an empty
132 // reasoning_content field as their final compatibility fallback.
133 sink.Flush()
134 return result
135 }
136
137 // CanReplayAssistantMessage lets the controller apply the provider-specific
138 // half of interrupted-turn validation without exposing the provider itself.
139 func (a *Agent) CanReplayAssistantMessage(m provider.Message) bool {
140 return a == nil || provider.CanReplayAssistantMessage(a.svc.prov, m)
141 }
142
143 // ensureUnreplayableHistoryRecovery installs one existing-format LocalOnly
144 // handoff before the new user turn is persisted. The malformed canonical turn
145 // stays available to the UI while the current provider receives only bounded
146 // structural recovery facts.
147 func (a *Agent) ensureUnreplayableHistoryRecovery() {
148 if a == nil || a.sess.conversation == nil {
149 return
150 }
151
152 msgs := a.sess.conversation.Snapshot()
153 latestBad := -1
154 recovery := &provider.InterruptedTurnRecovery{Pending: true}
155 for i, m := range msgs {
156 if m.Role != provider.RoleAssistant || provider.CanReplayAssistantMessage(a.svc.prov, m) {
157 continue
158 }
159 latestBad = i
160 results := map[string]provider.Message{}
161 for j := i + 1; j < len(msgs) && msgs[j].Role == provider.RoleTool && !msgs[j].LocalOnly; j++ {
162 results[msgs[j].ToolCallID+"\x00"+msgs[j].Name] = msgs[j]
163 }
164 for _, call := range m.ToolCalls {
165 name := strings.TrimSpace(call.Name)
166 if name == "" {
167 continue
168 }
169 state := provider.ToolRunUnknown
170 if result, ok := results[call.ID+"\x00"+name]; ok {
171 state = provider.ToolResultRunState(result)
172 }
173 provider.RecordToolRecovery(recovery, provider.InterruptedToolSummary{ID: call.ID, Name: name}, state)
174 }
175 for _, search := range m.ServerSearch {
176 if len(search.Results) > 0 || len(search.Raw) > 0 {
177 recovery.CompletedTools = append(recovery.CompletedTools, provider.InterruptedToolSummary{ID: search.ID, Name: "web_search"})
178 } else {
179 recovery.InterruptedTools = appendUniqueRecoveryName(recovery.InterruptedTools, "web_search")
180 }
181 }
182 }
183 if latestBad < 0 {
184 return
185 }
186 for _, m := range msgs[latestBad+1:] {
187 if m.LocalOnly && m.InterruptedTurn != nil {
188 return
189 }
190 }
191 _ = a.appendCommittedMessages(context.Background(), "reasoning-replay-recovery", provider.Message{
192 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID,
193 Name: provider.LocalOnlyToolName, LocalOnly: true, InterruptedTurn: recovery,
194 })
195 event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryHistoryRepaired})
196 }
197
198 func appendUniqueRecoveryName(dst []string, name string) []string {
199 if slices.Contains(dst, name) {
200 return dst
201 }
202 return append(dst, name)
203 }
204
204 lines GO