返回 DeepSeek-Reasonix
reasoning_replay.go
根目录 / internal / provider / reasoning_replay.go
1 package provider
2
3 import (
4 "slices"
5 "strings"
6
7 "reasonix/internal/nilutil"
8 )
9
10 // AssistantReasoningReplayPolicy is optionally implemented by providers whose
11 // replay contract depends on the concrete assistant message. It extends the
12 // legacy tool-calls-only policy to provider-executed activity such as Anthropic
13 // server_tool_use without changing existing provider implementations.
14 type AssistantReasoningReplayPolicy interface {
15 RequiresAssistantReasoningReplay(Message) bool
16 }
17
18 // RequiresAssistantReasoningReplay reports whether the exact provider-issued
19 // reasoning for m must survive storage and be replayed in later requests.
20 func RequiresAssistantReasoningReplay(p Provider, m Message) bool {
21 if nilutil.IsNil(p) {
22 return false
23 }
24 if policy, ok := p.(AssistantReasoningReplayPolicy); ok {
25 return policy.RequiresAssistantReasoningReplay(m)
26 }
27 if RequiresReasoningRoundTrip(p) {
28 return true
29 }
30 return len(m.ToolCalls) > 0 && RequiresToolCallReasoning(p)
31 }
32
33 // EmptyReasoningFallbackPolicy is optionally implemented by providers whose
34 // wire protocol accepts an assistant tool turn without provider-issued
35 // reasoning, either as an explicit empty field or by omitting an optional
36 // reasoning item. Anthropic thinking blocks do not have that fallback.
37 type EmptyReasoningFallbackPolicy interface {
38 AllowsEmptyReasoningFallback() bool
39 }
40
41 // AllowsEmptyReasoningFallback defaults to false so unknown protocols never
42 // fabricate a replayable reasoning block.
43 func AllowsEmptyReasoningFallback(p Provider) bool {
44 if nilutil.IsNil(p) {
45 return false
46 }
47 policy, ok := p.(EmptyReasoningFallbackPolicy)
48 return ok && policy.AllowsEmptyReasoningFallback()
49 }
50
51 // ProjectReasoningStrippedMessages is the catch-and-repair projection for a
52 // provider that rejected replayed thinking/reasoning history with
53 // ReasoningReplayError. It follows the vendor-documented self-heal recipe:
54 // strip every assistant message's reasoning/thinking metadata from the
55 // provider-visible projection, then drop the tool activity that can no longer
56 // be paired with its thinking block. Canonical session messages are never
57 // modified; a history with nothing to strip keeps its backing slice.
58 func ProjectReasoningStrippedMessages(p Provider, msgs []Message) ([]Message, bool) {
59 return projectReasoningStrippedMessages(p, msgs, len(msgs))
60 }
61
62 // ProjectReasoningStrippedMessagesPrefix applies the strong projection only
63 // to the provider-visible prefix that was present when a replay 400 was fixed.
64 // Messages appended after that prefix keep their normal reasoning/tool replay.
65 func ProjectReasoningStrippedMessagesPrefix(p Provider, msgs []Message, prefix int) ([]Message, bool) {
66 if prefix < 0 || prefix > len(msgs) {
67 return msgs, false
68 }
69 return projectReasoningStrippedMessages(p, msgs, prefix)
70 }
71
72 func projectReasoningStrippedMessages(p Provider, msgs []Message, prefix int) ([]Message, bool) {
73 work := msgs
74 stripped := false
75 for i, m := range msgs[:prefix] {
76 if m.Role != RoleAssistant {
77 continue
78 }
79 if m.ReasoningContent == "" && m.ReasoningSignature == "" && m.ReasoningID == "" && m.ReasoningStatus == "" && len(m.ThinkingBlocks) == 0 && len(m.ResponsesItems) == 0 && m.ReasoningState == "" {
80 continue
81 }
82 if !stripped {
83 work = append([]Message(nil), msgs...)
84 stripped = true
85 }
86 work[i].ReasoningContent = ""
87 work[i].ReasoningSignature = ""
88 work[i].ReasoningID = ""
89 work[i].ReasoningStatus = ""
90 work[i].ReasoningState = ReasoningIncomplete
91 work[i].ThinkingBlocks = nil
92 work[i].ResponsesItems = nil
93 }
94 projected, projectedChanged := projectReplaySafeMessages(p, work, prefix, false)
95 return projected, stripped || projectedChanged
96 }
97
98 // ProjectReplaySafeMessages returns the provider-visible projection for
99 // histories that contain assistant activity without the reasoning required to
100 // replay it. Canonical session messages are never modified. Healthy histories
101 // retain their backing slice so their wire bytes and prompt-cache prefix stay
102 // unchanged.
103 //
104 // For an unreplayable turn, visible assistant text is preserved as a plain
105 // message while provider-bound activity metadata and its contiguous client-tool
106 // results are omitted. Providers with an explicit empty-reasoning fallback do
107 // not need projection.
108 func ProjectReplaySafeMessages(p Provider, msgs []Message) ([]Message, bool) {
109 return projectReplaySafeMessages(p, msgs, len(msgs), true)
110 }
111
112 func projectReplaySafeMessages(p Provider, msgs []Message, prefix int, honorEmptyFallback bool) ([]Message, bool) {
113 msgs, converted := projectCompatibleReplay(p, msgs, prefix)
114
115 isUnreplayable := func(m Message) bool {
116 return m.Role == RoleAssistant &&
117 RequiresAssistantReasoningReplay(p, m) &&
118 !HasReplayableReasoning(p, m) && (!honorEmptyFallback || !CanReplayAssistantMessage(p, m))
119 }
120
121 if !slices.ContainsFunc(msgs[:prefix], isUnreplayable) {
122 return msgs, converted
123 }
124
125 out := make([]Message, 0, len(msgs))
126 for i := 0; i < len(msgs); {
127 m := msgs[i]
128 if i >= prefix || !isUnreplayable(m) {
129 out = append(out, m)
130 i++
131 continue
132 }
133
134 if strings.TrimSpace(m.Content) != "" {
135 plain := m
136 plain.ReasoningContent = ""
137 plain.ReasoningSignature = ""
138 plain.ReasoningID = ""
139 plain.ReasoningStatus = ""
140 plain.ReasoningState = ""
141 plain.ThinkingBlocks = nil
142 plain.ToolCalls = nil
143 plain.ResponsesItems = nil
144 plain.ServerSearch = nil
145 out = append(out, plain)
146 }
147 i++
148 if len(m.ToolCalls) > 0 {
149 for i < len(msgs) && msgs[i].Role == RoleTool && !msgs[i].LocalOnly {
150 i++
151 }
152 }
153 }
154 return out, true
155 }
156
156 lines GO