返回 DeepSeek-Reasonix
sampling_request.go
根目录 / internal / agent / sampling_request.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9
10 "reasonix/internal/i18n"
11 "reasonix/internal/provider"
12 )
13
14 // samplingRequest is a once-prepared, frozen provider request for one model
15 // round. All stream retries replay this exact payload — no synthetic recovery
16 // messages, no schema reorder, no previous_response_id drift from failed attempts.
17 type samplingRequest struct {
18 req provider.Request
19 }
20
21 func isEmptyStreamResult(text, reasoning string, calls []provider.ToolCall, responsesItems []json.RawMessage, serverSearch []provider.ServerSearchCall) bool {
22 return strings.TrimSpace(text) == "" &&
23 strings.TrimSpace(reasoning) == "" &&
24 len(calls) == 0 &&
25 len(responsesItems) == 0 &&
26 len(serverSearch) == 0
27 }
28
29 // modelInputMessages derives the stable provider-visible view from durable
30 // storage. Tool Content is the first-visible bounded result; RawContent stays
31 // local and is available only through the explicit session result reader.
32 func modelInputMessages(msgs []provider.Message) []provider.Message {
33 return provider.ModelMessages(msgs)
34 }
35
36 // normalizeModelRequestMessages is shared by ordinary sampling and compaction
37 // replay so their cacheable prefix has the same role projection and metadata
38 // cleanup. Interceptors deliberately remain outside this helper.
39 func (a *Agent) normalizeModelRequestMessages(msgs []provider.Message) []provider.Message {
40 requestMessages := a.providerProjectionMessages(modelInputMessages(provider.RepairHistoryForReplay(msgs)))
41 // ModelMessages intentionally has a zero-copy fast path for clean input.
42 // Detach before removing local metadata from the request-only representation.
43 requestMessages = append([]provider.Message(nil), requestMessages...)
44 for i := range requestMessages {
45 requestMessages[i].CreatedAt = 0
46 if requestMessages[i].Role == provider.RoleUser {
47 requestMessages[i].Content = reTrailingExecutionPolicy.ReplaceAllString(requestMessages[i].Content, "")
48 }
49 }
50 return requestMessages
51 }
52
53 func (a *Agent) resolveRequestImages(ctx context.Context, msgs []provider.Message) ([]provider.Message, error) {
54 if a == nil {
55 return msgs, nil
56 }
57 for _, msg := range msgs {
58 if !msg.LocalOnly && len(msg.ImageInputs) > 0 {
59 if a.imageResolver == nil {
60 return nil, errors.New("image request resolver is unavailable")
61 }
62 if routed, ok := a.imageResolver.(interface {
63 ResolveRequestImagesForModel(context.Context, []provider.Message, string, bool) ([]provider.Message, error)
64 }); ok {
65 return routed.ResolveRequestImagesForModel(ctx, msgs, a.modelRef, a.imageInput.native)
66 }
67 return a.imageResolver.ResolveRequestImages(ctx, msgs)
68 }
69 }
70 return msgs, nil
71 }
72
73 func (a *Agent) streamProviderRequest(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
74 if err := provider.ValidateModelTranscript(req.Messages); err != nil {
75 return nil, err
76 }
77 if err := a.checkpointSession(ctx, CheckpointBeforeModel); err != nil {
78 return nil, err
79 }
80 ch, err := provider.Stream(ctx, a.svc.prov, req)
81 if err != nil {
82 if limit := provider.AsOutputLimitError(err); !provider.ManagedRecovery(ctx) && limit != nil && req.MaxTokens > limit.MaxOutputTokens {
83 a.learnOutputBudget(limit.MaxOutputTokens)
84 retryReq := req
85 retryReq.MaxTokens = limit.MaxOutputTokens
86 if checkpointErr := a.checkpointSession(ctx, CheckpointBeforeModel); checkpointErr != nil {
87 return nil, checkpointErr
88 }
89 return provider.Stream(ctx, a.svc.prov, retryReq)
90 }
91 return nil, err
92 }
93 // HTTP-level output-limit errors are returned before a stream channel is
94 // created by SendWithRetry. Preserve the original channel directly so
95 // cancellation and live chunk timing remain unchanged.
96 return ch, nil
97 }
98
99 // prepareSamplingRequest freezes one model-round request (preflight + interceptors).
100 // Output budgets are resolved only here and never change the compact_ratio
101 // trigger. Physical overflow may attempt at most one recovery summary.
102 func (a *Agent) prepareSamplingRequest(ctx context.Context) (result samplingRequest, requestErr error) {
103 work, finish := a.beginCompactionRun(ctx)
104 // Keep the ordinary request context alive after a safe pressure timeout,
105 // while every nested maintenance attempt shares the bounded work context.
106 ctx = context.WithValue(ctx, compactionRunKey{}, currentCompactionRun(work))
107 defer func() {
108 requestErr = finish(requestErr)
109 if errors.Is(requestErr, ErrCompactionRequired) {
110 requestErr = fmt.Errorf("%s: %w", i18n.M.ContextLimitRecovery, requestErr)
111 }
112 }()
113 // Recover an accepted context-maintenance event before ContextManager can
114 // perform more maintenance or freeze a request from unconfirmed state.
115 if err := a.confirmPendingModelContext(ctx); err != nil {
116 return samplingRequest{}, err
117 }
118 frozen, err := a.buildSamplingRequest(ctx, CompactionTriggerPressure)
119 if err != nil {
120 return samplingRequest{}, err
121 }
122 if err := a.applyAdmissionToRequest(&frozen.req); err != nil {
123 // One-shot physical overflow recovery. Do not loop.
124 startProjectionVersion := a.currentProjectionVersion()
125 if _, perr := a.contextManager().Prepare(ctx, ContextPreparePolicy{
126 Trigger: CompactionTriggerOverflow,
127 Force: true,
128 }); perr != nil {
129 return samplingRequest{}, perr
130 }
131 if a.currentProjectionVersion() <= startProjectionVersion {
132 return samplingRequest{}, err
133 }
134 rebuilt, rerr := a.buildSamplingRequest(ctx, CompactionTriggerPressure)
135 if rerr != nil {
136 return samplingRequest{}, rerr
137 }
138 if aerr := a.applyAdmissionToRequest(&rebuilt.req); aerr != nil {
139 return samplingRequest{}, aerr
140 }
141 shape := a.requestCalibrationShape(rebuilt.req)
142 a.sess.output.activeReqShape.Store(&shape)
143 return samplingRequest{req: freezeProviderRequest(rebuilt.req)}, nil
144 }
145 shape := a.requestCalibrationShape(frozen.req)
146 a.sess.output.activeReqShape.Store(&shape)
147 return samplingRequest{req: freezeProviderRequest(frozen.req)}, nil
148 }
149
150 func (a *Agent) buildSamplingRequest(ctx context.Context, trigger string) (samplingRequest, error) {
151 // CreatedAt is durable UI metadata, not model input. Strip it from the
152 // transport copy so wall-clock differences never invalidate the provider's
153 // prompt-cache prefix (and custom providers cannot accidentally send it).
154 prepared, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: trigger})
155 if err != nil {
156 return samplingRequest{}, err
157 }
158 requestMessages, err := a.resolveRequestImages(ctx, prepared.Messages)
159 if err != nil {
160 return samplingRequest{}, err
161 }
162 requestMessages = a.normalizeModelRequestMessages(requestMessages)
163 // context.prepare: extensions may rewrite the message copy feeding THIS
164 // request. The session log is never touched — the replacement is
165 // ephemeral, so the next request starts from the unmodified history.
166 requestMessages, err = a.interceptContextPrepare(ctx, requestMessages)
167 if err != nil {
168 return samplingRequest{}, err
169 }
170 req := provider.Request{
171 Messages: requestMessages,
172 Tools: a.providerToolSchemas(),
173 MaxTokens: a.maxOutputTokens,
174 Temperature: provider.OptionalTemperature(a.temperature),
175 ResponseFormat: responseFormatFromRequest(ctx),
176 }
177 if provider.NativeToolSearchEnabled(a.svc.prov) {
178 req.ToolSearch = &provider.ToolSearch{Enabled: true}
179 }
180 // provider.request: the fully assembled request gets one last ruling
181 // (revalidated by the payload registry) before it goes on the wire.
182 req, err = a.interceptProviderRequest(ctx, req)
183 if err != nil {
184 return samplingRequest{}, err
185 }
186 if err := provider.ValidateModelTranscript(req.Messages); err != nil {
187 return samplingRequest{}, fmt.Errorf("%s: %w", i18n.M.ExtensionRequestRecovery, err)
188 }
189 return samplingRequest{req: req}, nil
190 }
191
192 // providerProjectionMessages applies provider-specific role compatibility to a
193 // request copy. Projection sidecars retain logical user-turn boundaries so
194 // explicit range compression can continue to resolve anchors across calls.
195 func (a *Agent) providerProjectionMessages(msgs []provider.Message) []provider.Message {
196 if a != nil {
197 strongCutoff := a.sess.reasoningReplayStrongProjection
198 if strongCutoff > 0 && a.strictAlternatingRoles {
199 // The cutoff is measured after role coalescing on the repaired
200 // request, so apply the same outbound shape before slicing it.
201 msgs = coalesceProjectionUserRuns(msgs)
202 }
203 if strongCutoff > 0 {
204 // A repaired thinking-400 conversation keeps the stripped
205 // projection only for the history that caused the rejection.
206 resolvedCutoff := resolveReasoningReplayPrefix(msgs, strongCutoff, a.sess.reasoningReplayStrongProjectionAnchor)
207 if resolvedCutoff > 0 {
208 if repaired, changed := provider.ProjectReasoningStrippedMessagesPrefix(a.svc.prov, msgs, resolvedCutoff); changed {
209 msgs = a.replayRecoveryFacts(msgs[:resolvedCutoff], repaired)
210 }
211 } else {
212 // The canonical shape no longer contains the repair anchor
213 // (for example after rewind). Do not silently disable all
214 // provider projection; re-arm from the current history.
215 a.sess.clearReasoningReplayStrongProjection()
216 if repaired, changed := provider.ProjectReplaySafeMessages(a.svc.prov, msgs); changed {
217 msgs = repaired
218 }
219 }
220 } else if repaired, changed := provider.ProjectReplaySafeMessages(a.svc.prov, msgs); changed {
221 msgs = repaired
222 }
223 if a.strictAlternatingRoles && a.sess.reasoningReplayStrongProjection <= 0 {
224 return coalesceProjectionUserRuns(msgs)
225 }
226 }
227 return msgs
228 }
229
230 // freezeProviderRequest deep-copies the provider-visible request surface so
231 // retries share identical messages, tools order, temperature, and format.
232 func freezeProviderRequest(req provider.Request) provider.Request {
233 out := req
234 if len(req.Messages) > 0 {
235 out.Messages = append([]provider.Message(nil), req.Messages...)
236 for i := range out.Messages {
237 out.Messages[i].ThinkingBlocks = append([]provider.ThinkingBlock(nil), out.Messages[i].ThinkingBlocks...)
238 if len(out.Messages[i].ToolCalls) > 0 {
239 out.Messages[i].ToolCalls = append([]provider.ToolCall(nil), out.Messages[i].ToolCalls...)
240 }
241 if len(out.Messages[i].Images) > 0 {
242 out.Messages[i].Images = append([]string(nil), out.Messages[i].Images...)
243 }
244 if len(out.Messages[i].ImageInputs) > 0 {
245 out.Messages[i].ImageInputs = provider.CloneImageInputs(out.Messages[i].ImageInputs)
246 }
247 if len(out.Messages[i].ResponsesItems) > 0 {
248 items := make([]json.RawMessage, len(out.Messages[i].ResponsesItems))
249 for j, item := range out.Messages[i].ResponsesItems {
250 items[j] = append(json.RawMessage(nil), item...)
251 }
252 out.Messages[i].ResponsesItems = items
253 }
254 if len(out.Messages[i].ServerSearch) > 0 {
255 searches := make([]provider.ServerSearchCall, len(out.Messages[i].ServerSearch))
256 for j, search := range out.Messages[i].ServerSearch {
257 searches[j] = search
258 if len(search.Results) > 0 {
259 searches[j].Results = append([]provider.ServerSearchHit(nil), search.Results...)
260 }
261 if len(search.Raw) > 0 {
262 searches[j].Raw = append(json.RawMessage(nil), search.Raw...)
263 }
264 }
265 out.Messages[i].ServerSearch = searches
266 }
267 }
268 }
269 if len(req.Tools) > 0 {
270 out.Tools = make([]provider.ToolSchema, len(req.Tools))
271 for i, schema := range req.Tools {
272 out.Tools[i] = schema
273 if len(schema.Parameters) > 0 {
274 out.Tools[i].Parameters = append(json.RawMessage(nil), schema.Parameters...)
275 }
276 }
277 }
278 if req.Temperature != nil {
279 t := *req.Temperature
280 out.Temperature = &t
281 }
282 if req.ResponseFormat != nil {
283 rf := *req.ResponseFormat
284 out.ResponseFormat = &rf
285 }
286 return out
287 }
288
288 lines GO