返回 DeepSeek-Reasonix
context_manager.go
根目录 / internal / agent / context_manager.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "sync/atomic"
9
10 "reasonix/internal/provider"
11 )
12
13 // compactionProgress is how compaction is faring in this session: whether a
14 // fold stopped reducing, how many ran back to back, and which retries already
15 // ran in the active turn. The fields are cleared together on lineage resets.
16 type compactionProgress struct {
17 stuck bool // a fold landed above the trigger, so the same-view pressure retry is pointless
18 stuckInputHash string // provider-visible view covered by stuck; changed input may retry
19 consecutive int // back-to-back folds since one last helped
20 // failedTurn backs off changed-view retries within one active tool loop.
21 // A later user turn may retry, while hard-ceiling recovery bypasses it.
22 failedTurn atomic.Int64
23 // lastTurn stops the post-turn observer and the pre-send preflight from
24 // paying for two summaries during one active tool loop.
25 lastTurn atomic.Int64
26 }
27
28 // ContextManager is the sole owner of provider-visible context maintenance.
29 // Canonical session messages are immutable inputs; Prepare evolves only the
30 // durable projection and returns the exact visible view for one sampling round.
31 type ContextManager struct {
32 agent *Agent
33 }
34
35 // ContextPreparePolicy describes one maintenance transaction.
36 type ContextPreparePolicy struct {
37 Trigger string
38 Instructions string
39 Force bool
40 // ObservedInputTokens is used by compatibility harnesses that invoke the
41 // old post-turn shim directly. Production Prepare estimates the current view
42 // from its calibrated final request shape.
43 ObservedInputTokens int
44 // AllowChunkedFallback enables fragment/tree-reduce recovery after a single
45 // summary fails. Ordinary pressure/overflow leave this false.
46 AllowChunkedFallback bool
47 }
48
49 // PreparedContext is the frozen result of a successful Prepare transaction.
50 type PreparedContext struct {
51 Messages []provider.Message
52 InputTokens int
53 ProjectionVersion uint64
54 }
55
56 func (a *Agent) contextManager() ContextManager { return ContextManager{agent: a} }
57
58 // PrepareContext is the public automatic-maintenance entry used by smoke tools
59 // and controllers that need a one-shot Prepare without sampling.
60 func (a *Agent) PrepareContext(ctx context.Context) error {
61 _, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: CompactionTriggerPressure})
62 return err
63 }
64
65 // ObserveUsage is retained as a compatibility hook. Usage observations never
66 // mutate the provider-visible checkpoint.
67 func (m ContextManager) ObserveUsage(u *provider.Usage) {
68 _ = u
69 }
70
71 // Prepare is the sole automatic maintenance entry. Below compact_ratio it does
72 // nothing. At or above the trigger it runs one single-flight prune/summary
73 // transaction, with at most two successful summary attempts under pressure.
74 func (m ContextManager) Prepare(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) {
75 // Legacy desktop callers can compact before their runtime context is installed.
76 if ctx == nil {
77 ctx = context.Background()
78 }
79 if err := ctx.Err(); err != nil {
80 return PreparedContext{}, err
81 }
82 if policy.Trigger == "" {
83 policy.Trigger = CompactionTriggerPressure
84 }
85 if m.agent == nil {
86 return PreparedContext{}, nil
87 }
88 m.agent.sess.compactionRunMu.Lock()
89 defer m.agent.sess.compactionRunMu.Unlock()
90 // Cancellation may have arrived while another maintenance transaction held the lock.
91 // Reject it before any fast path or projection maintenance can run.
92 if err := ctx.Err(); err != nil {
93 return PreparedContext{}, err
94 }
95 return m.prepareOnce(ctx, policy)
96 }
97
98 func (m ContextManager) prepareOnce(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) {
99 a := m.agent
100 if a == nil || a.sess.conversation == nil {
101 return PreparedContext{}, nil
102 }
103 visible := a.modelVisibleMessages()
104 // Threshold uses the stable pre-interceptor request shape (messages + tools
105 // + role projection). Extension interceptors run only on the real sampling
106 // request so side-effecting plugins are not double-invoked; if they expand
107 // the prompt past the hard ceiling, overflow recovery still fires.
108 est := a.estimatedVisibleRequestTokens(visible)
109 viewEst := est
110 prepared := PreparedContext{
111 Messages: append([]provider.Message(nil), visible...),
112 InputTokens: est,
113 ProjectionVersion: a.currentProjectionVersion(),
114 }
115 if a.contextWindow <= 0 || len(visible) == 0 {
116 return prepared, nil
117 }
118 fold := a.compactTrigger()
119 hard := a.hardInputCeiling()
120 if policy.ObservedInputTokens > 0 {
121 est = policy.ObservedInputTokens
122 prepared.InputTokens = est
123 }
124 inputHash := a.contextMaintenanceInputHash(visible)
125 // Receipts back off sub-critical retries only. A failed summary never
126 // fabricates a digest; at the ceiling the lossy truncation rescue is the
127 // last resort, so the turn still leaves with a view the provider accepts.
128 if blocked, _ := a.contextMaintenanceBlocked(inputHash, viewEst); blocked && policy.Trigger != CompactionTriggerManual &&
129 policy.Trigger != CompactionTriggerOverflow && est < hard {
130 return prepared, nil
131 }
132 if est < fold {
133 a.resetCompactionProgress()
134 }
135 if a.sess.compaction.stuck && a.sess.compaction.stuckInputHash != inputHash {
136 // The previous projection could not reclaim enough from its exact view,
137 // but newly appended messages create a new fold boundary and may retry.
138 a.sess.compaction.stuck = false
139 a.sess.compaction.stuckInputHash = ""
140 a.sess.compaction.consecutive = 0
141 }
142 if a.sess.compaction.stuck && policy.Trigger == CompactionTriggerPressure && est < hard {
143 return prepared, nil
144 }
145 // One user trigger. Overflow is a one-shot physical recovery path only.
146 forceFold := policy.Force || policy.Trigger == CompactionTriggerManual || policy.Trigger == CompactionTriggerOverflow || est >= hard
147 if est < fold && !forceFold {
148 return prepared, nil
149 }
150
151 // A manual compact over the hard ceiling is a rescue, not a convenience:
152 // prune first so the never-folded recent tail can shrink too.
153 if shouldPruneBeforeFold(policy.Trigger, est >= hard) {
154 applied, err := a.pruneToolResultsToProjectionLocked(policy.Trigger)
155 if err != nil {
156 return PreparedContext{}, err
157 }
158 if applied {
159 prepared = m.currentPrepared()
160 est = prepared.InputTokens
161 inputHash = a.contextMaintenanceInputHash(prepared.Messages)
162 if (policy.Trigger == CompactionTriggerPressure && est < fold) ||
163 (policy.Trigger == CompactionTriggerOverflow && est < hard) {
164 return prepared, nil
165 }
166 }
167 }
168
169 return m.foldContext(ctx, prepared, policy, inputHash, est, fold, hard, forceFold)
170 }
171
172 func shouldPruneBeforeFold(trigger string, overHardCeiling bool) bool {
173 switch trigger {
174 case CompactionTriggerPressure, CompactionTriggerOverflow:
175 return true
176 case CompactionTriggerManual:
177 return overHardCeiling
178 default:
179 return false
180 }
181 }
182
183 // manualRecoverySummaries bounds the rescue loop for a manual compact that
184 // starts at or above the hard input ceiling. Each batch folds the largest
185 // admissible prefix, so a handful of batches recovers even a view several
186 // times the window while capping summarizer spend on pathological input.
187 const manualRecoverySummaries = 4
188
189 func maxSummariesFor(policy ContextPreparePolicy, overCeiling bool) int {
190 switch {
191 case policy.Trigger == CompactionTriggerManual && overCeiling:
192 return manualRecoverySummaries
193 case policy.Trigger == CompactionTriggerPressure:
194 return 2
195 default:
196 return 1
197 }
198 }
199
200 func (m ContextManager) foldContext(ctx context.Context, prepared PreparedContext, policy ContextPreparePolicy, inputHash string, est, fold, hard int, forceFold bool) (PreparedContext, error) {
201 a := m.agent
202 maxSummaries := maxSummariesFor(policy, est >= hard)
203 ladder := newSummaryLadder(maxSummaries)
204 result := prepared
205 for ladder.next() {
206 mustFree := policy.Trigger == CompactionTriggerOverflow || result.InputTokens >= hard
207 outcome, err := a.compactToProjectionLocked(ctx, policy.Trigger, policy.Instructions,
208 ladder.request(forceFold, mustFree, policy.AllowChunkedFallback))
209 if err != nil {
210 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
211 return PreparedContext{}, err
212 }
213 if ladder.absorbOverflow(err) {
214 continue
215 }
216 return m.summaryFailed(policy, inputHash, hard, err)
217 }
218 if outcome == CompactionNoop {
219 return m.summaryNoop(policy, inputHash, hard)
220 }
221
222 result = m.currentPrepared()
223 if foldLanded(policy, result.InputTokens, fold, hard) {
224 a.resetCompactionProgress()
225 return result, nil
226 }
227 forceFold = false
228 inputHash = a.contextMaintenanceInputHash(result.Messages)
229 }
230
231 reason := fmt.Sprintf("summary result remains above fold trigger after %d attempts (%d >= %d)", maxSummaries, result.InputTokens, fold)
232 blockedInputHash := a.contextMaintenanceInputHash(result.Messages)
233 a.recordContextMaintenanceBlocked(blockedInputHash, policy.Trigger, "summary", reason)
234 a.sess.compaction.stuck = true
235 a.sess.compaction.stuckInputHash = blockedInputHash
236 a.sess.compaction.consecutive += maxSummaries
237 if policy.Trigger == CompactionTriggerOverflow || result.InputTokens >= hard {
238 return m.rescueByTruncation(policy, hard, errors.New(reason))
239 }
240 slog.Info("agent: context maintenance paused below hard ceiling", "reason", reason)
241 return result, nil
242 }
243
244 func foldLanded(policy ContextPreparePolicy, tokens, fold, hard int) bool {
245 switch policy.Trigger {
246 case CompactionTriggerManual, CompactionTriggerOverflow:
247 return tokens < hard || tokens < fold
248 default:
249 return tokens < fold
250 }
251 }
252
253 func (m ContextManager) summaryFailed(policy ContextPreparePolicy, inputHash string, hard int, err error) (PreparedContext, error) {
254 a := m.agent
255 if errors.Is(err, errCompressStaleContext) && policy.Trigger != CompactionTriggerManual {
256 reason := "context changed during summary; automatic retry blocked for this generation"
257 a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason)
258 return m.rescueOrFail(policy, hard, errors.New(reason))
259 }
260 status := "failed"
261 if errors.Is(err, errSummaryOutputTruncated) || errors.Is(err, errCheckpointRejected) {
262 status = "blocked"
263 }
264 a.recordContextMaintenanceOutcome(inputHash, policy.Trigger, "summary", status, fmt.Sprintf("context summary failed: %v", err))
265 return m.rescueOrFail(policy, hard, err)
266 }
267
268 func (m ContextManager) summaryNoop(policy ContextPreparePolicy, inputHash string, hard int) (PreparedContext, error) {
269 reason := "context is above the maintenance threshold but no foldable region remains"
270 m.agent.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason)
271 latest := m.currentPrepared()
272 switch {
273 case policy.Trigger == CompactionTriggerOverflow || latest.InputTokens >= hard:
274 return m.rescueByTruncation(policy, hard, errors.New(reason))
275 case policy.Force:
276 return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
277 default:
278 return latest, nil
279 }
280 }
281
282 // rescueOrFail decides what a failed summary means: below the ceiling
283 // automatic maintenance waits for the next view and a manual compact reports
284 // the error; at or above the ceiling only the lossy truncation rescue is left.
285 func (m ContextManager) rescueOrFail(policy ContextPreparePolicy, hard int, cause error) (PreparedContext, error) {
286 latest := m.currentPrepared()
287 if policy.Trigger != CompactionTriggerOverflow && latest.InputTokens < hard {
288 if policy.Trigger == CompactionTriggerManual {
289 return PreparedContext{}, cause
290 }
291 return latest, nil
292 }
293 return m.rescueByTruncation(policy, hard, cause)
294 }
295
296 // rescueByTruncation installs the lossy truncation projection aimed at the
297 // fold trigger so the turn leaves the ceiling with headroom. cause is the
298 // summary failure it stands in for and stays in the error when even that fails.
299 func (m ContextManager) rescueByTruncation(policy ContextPreparePolicy, hard int, cause error) (PreparedContext, error) {
300 a := m.agent
301 applied, err := a.truncateToProjectionLocked(policy.Trigger, a.compactTrigger())
302 if err != nil {
303 return PreparedContext{}, fmt.Errorf("%w: %w (truncation: %w)", ErrCompactionRequired, cause, err)
304 }
305 if !applied {
306 return PreparedContext{}, fmt.Errorf("%w: %w", ErrCompactionRequired, cause)
307 }
308 latest := m.currentPrepared()
309 if latest.InputTokens >= hard {
310 return PreparedContext{}, fmt.Errorf("%w: truncated view still %d >= %d", ErrCompactionRequired, latest.InputTokens, hard)
311 }
312 a.resetCompactionProgress()
313 return latest, nil
314 }
315
316 func (a *Agent) resetCompactionProgress() {
317 a.sess.compaction.stuck = false
318 a.sess.compaction.stuckInputHash = ""
319 a.sess.compaction.consecutive = 0
320 a.sess.compaction.failedTurn.Store(0)
321 }
322
323 func (m ContextManager) currentPrepared() PreparedContext {
324 if m.agent == nil {
325 return PreparedContext{}
326 }
327 visible := m.agent.modelVisibleMessages()
328 return PreparedContext{
329 Messages: append([]provider.Message(nil), visible...),
330 InputTokens: m.agent.estimatedVisibleRequestTokens(visible),
331 ProjectionVersion: m.agent.currentProjectionVersion(),
332 }
333 }
334
335 // estimatedVisibleRequestTokens sizes the pre-interceptor sampling shape:
336 // ModelMessages + role projection + tool schemas. Extension interceptors are
337 // intentionally omitted here (see prepareOnce) to avoid double side effects.
338 func (a *Agent) estimatedVisibleRequestTokens(visible []provider.Message) int {
339 if a == nil {
340 return 0
341 }
342 msgs := a.normalizeModelRequestMessages(visible)
343 tools := a.providerToolSchemas()
344 return a.estimatedRequestTokens(provider.Request{
345 Messages: msgs,
346 Tools: tools,
347 MaxTokens: a.maxOutputTokens,
348 Temperature: provider.OptionalTemperature(a.temperature),
349 })
350 }
351
351 lines GO