| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | |
| 7 | "reasonix/internal/event" |
| 8 | "reasonix/internal/i18n" |
| 9 | "reasonix/internal/provider" |
| 10 | ) |
| 11 | |
| 12 | type contextRecoveryBudget struct { |
| 13 | retries int |
| 14 | } |
| 15 | |
| 16 | func (a *Agent) recoverContextLimit(ctx context.Context, frozen samplingRequest, err error, budget *contextRecoveryBudget) (samplingRequest, bool, string) { |
| 17 | limit := provider.AsContextLimitError(err) |
| 18 | if a == nil || limit == nil || budget == nil { |
| 19 | return samplingRequest{}, false, contextRecoveryFailed |
| 20 | } |
| 21 | omitted := frozen.req.MaxTokens == 0 |
| 22 | if limit.PromptTokens > 0 { |
| 23 | a.setPromptTokenCalibrationFromActive(limit.PromptTokens) |
| 24 | } |
| 25 | a.learnContextBudget(limit.WindowTokens, limit.CompletionTokens, omitted) |
| 26 | adm := a.lastAdmission() |
| 27 | adm.ObservedWindow = limit.WindowTokens |
| 28 | adm.ObservedPrompt = limit.PromptTokens |
| 29 | adm.ObservedCompletion = limit.CompletionTokens |
| 30 | a.storeAdmission(adm) |
| 31 | |
| 32 | window := a.effectiveContextWindow() |
| 33 | prompt := limit.PromptTokens |
| 34 | if prompt <= 0 { |
| 35 | prompt = a.estimatedRequestTokens(frozen.req) |
| 36 | } |
| 37 | physical := window - prompt - outputBudgetReserve |
| 38 | // An overflow without token numbers cannot size a retry: the estimate that |
| 39 | // admitted the request is the number the provider just rejected. |
| 40 | if limit.PromptTokens <= 0 && limit.WindowTokens <= 0 { |
| 41 | physical = 0 |
| 42 | } |
| 43 | if physical > 0 && budget.retries == 0 { |
| 44 | next := freezeProviderRequest(frozen.req) |
| 45 | next.MaxTokens = physical |
| 46 | if frozen.req.MaxTokens > 0 && frozen.req.MaxTokens < physical { |
| 47 | next.MaxTokens = frozen.req.MaxTokens |
| 48 | } |
| 49 | budget.retries++ |
| 50 | // Publish the request that will actually be retried, not the stale |
| 51 | // pre-error admission. The Context Panel reads this atomic snapshot while |
| 52 | // the turn is still active and after it completes. |
| 53 | adm.WindowMode = provider.ContextWindowShared.String() |
| 54 | adm.Source = provider.ContextBudgetSourceLearned |
| 55 | adm.WindowTokens = window |
| 56 | adm.PromptTokens = prompt |
| 57 | adm.PhysicalRemaining = physical |
| 58 | if adm.RequestedOutputTokens <= 0 { |
| 59 | adm.RequestedOutputTokens = limit.CompletionTokens |
| 60 | } |
| 61 | if omitted && adm.AutoOutputTokens <= 0 { |
| 62 | adm.AutoOutputTokens = limit.CompletionTokens |
| 63 | } |
| 64 | adm.EffectiveOutputTokens = next.MaxTokens |
| 65 | adm.Clipped = adm.RequestedOutputTokens > 0 && next.MaxTokens < adm.RequestedOutputTokens |
| 66 | adm.ApplyMaxTokens = next.MaxTokens > 0 |
| 67 | adm.LastRecovery = contextRecoveryLearnedRetry |
| 68 | a.storeAdmission(adm) |
| 69 | a.emitContextRecoveryNotice(contextRecoveryLearnedRetry, limit, next.MaxTokens) |
| 70 | shape := a.requestCalibrationShape(next) |
| 71 | a.sess.output.activeReqShape.Store(&shape) |
| 72 | return samplingRequest{req: next}, true, contextRecoveryLearnedRetry |
| 73 | } |
| 74 | if physical <= 0 && budget.retries == 0 { |
| 75 | startProjectionVersion := a.currentProjectionVersion() |
| 76 | if _, perr := a.contextManager().Prepare(ctx, ContextPreparePolicy{ |
| 77 | Trigger: CompactionTriggerOverflow, |
| 78 | Force: true, |
| 79 | }); perr != nil { |
| 80 | a.setLastRecovery(contextRecoveryFailed) |
| 81 | return samplingRequest{}, false, contextRecoveryFailed |
| 82 | } |
| 83 | if a.currentProjectionVersion() <= startProjectionVersion { |
| 84 | a.setLastRecovery(contextRecoveryFailed) |
| 85 | return samplingRequest{}, false, contextRecoveryFailed |
| 86 | } |
| 87 | rebuilt, rerr := a.buildSamplingRequest(ctx, CompactionTriggerPressure) |
| 88 | if rerr != nil { |
| 89 | a.setLastRecovery(contextRecoveryFailed) |
| 90 | return samplingRequest{}, false, contextRecoveryFailed |
| 91 | } |
| 92 | if aerr := a.applyAdmissionToRequest(&rebuilt.req); aerr != nil { |
| 93 | a.setLastRecovery(contextRecoveryFailed) |
| 94 | return samplingRequest{}, false, contextRecoveryFailed |
| 95 | } |
| 96 | budget.retries++ |
| 97 | a.setLastRecovery(contextRecoveryCompacted) |
| 98 | a.emitContextRecoveryNotice(contextRecoveryCompacted, limit, rebuilt.req.MaxTokens) |
| 99 | shape := a.requestCalibrationShape(rebuilt.req) |
| 100 | a.sess.output.activeReqShape.Store(&shape) |
| 101 | return samplingRequest{req: freezeProviderRequest(rebuilt.req)}, true, contextRecoveryCompacted |
| 102 | } |
| 103 | a.setLastRecovery(contextRecoveryFailed) |
| 104 | return samplingRequest{}, false, contextRecoveryFailed |
| 105 | } |
| 106 | |
| 107 | func (a *Agent) emitContextRecoveryNotice(kind string, limit *provider.ContextLimitError, nextOutput int) { |
| 108 | if a == nil || a.svc.sink == nil { |
| 109 | return |
| 110 | } |
| 111 | text := i18n.M.ContextRecoveryAdjustBudget |
| 112 | if kind == contextRecoveryCompacted { |
| 113 | text = i18n.M.ContextRecoveryCompacted |
| 114 | } |
| 115 | detail := fmt.Sprintf("recovery=%s next_output=%d", kind, nextOutput) |
| 116 | if limit != nil { |
| 117 | detail = fmt.Sprintf("%s window=%d prompt=%d completion=%d requested=%d", |
| 118 | detail, limit.WindowTokens, limit.PromptTokens, limit.CompletionTokens, limit.RequestedTokens) |
| 119 | } |
| 120 | a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text, Detail: detail}) |
| 121 | } |
| 122 |