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