| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "reasonix/internal/event" |
| 5 | "reasonix/internal/i18n" |
| 6 | "reasonix/internal/provider" |
| 7 | ) |
| 8 | |
| 9 | // reasoningReplayRecoveryBudget bounds the thinking-400 catch-and-repair to one |
| 10 | // retry per model round and records the repaired provider-message prefix. |
| 11 | type reasoningReplayRecoveryBudget struct { |
| 12 | persisted bool |
| 13 | retries int |
| 14 | local bool |
| 15 | cutoff int |
| 16 | anchor string |
| 17 | } |
| 18 | |
| 19 | // recoverReasoningReplay400 applies the vendor-documented self-heal for a |
| 20 | // provider that rejected replayed thinking history with HTTP 400: rebuild the |
| 21 | // frozen request's messages through the strong projection and retry exactly |
| 22 | // once. Everything except Messages stays byte-identical to the rejected |
| 23 | // request; the original history boundary bounds future projection, including |
| 24 | // a trailing tool pair that disappears from the repaired view. |
| 25 | func (a *Agent) recoverReasoningReplay400(frozen samplingRequest, err error, budget *reasoningReplayRecoveryBudget) (samplingRequest, bool) { |
| 26 | if a == nil || budget == nil || budget.retries > 0 || a.protocolRecoverySpent() { |
| 27 | return samplingRequest{}, false |
| 28 | } |
| 29 | if provider.AsReasoningReplayError(err) == nil { |
| 30 | return samplingRequest{}, false |
| 31 | } |
| 32 | return a.recoverReasoningReplayHistory(frozen, budget) |
| 33 | } |
| 34 | |
| 35 | func (a *Agent) recoverReasoningReplayHistory(frozen samplingRequest, budget *reasoningReplayRecoveryBudget) (samplingRequest, bool) { |
| 36 | if a == nil || budget == nil || budget.retries > 0 || a.protocolRecoverySpent() { |
| 37 | return samplingRequest{}, false |
| 38 | } |
| 39 | repaired, changed := provider.ProjectReasoningStrippedMessages(a.svc.prov, frozen.req.Messages) |
| 40 | if !changed { |
| 41 | return samplingRequest{}, false |
| 42 | } |
| 43 | budget.retries++ |
| 44 | budget.cutoff = len(frozen.req.Messages) |
| 45 | if budget.cutoff > 0 { |
| 46 | budget.anchor = reasoningReplayMessageFingerprint(frozen.req.Messages[budget.cutoff-1]) |
| 47 | } |
| 48 | next := frozen.req |
| 49 | next.Messages = a.replayRecoveryFacts(frozen.req.Messages, repaired) |
| 50 | return samplingRequest{req: next}, true |
| 51 | } |
| 52 | |
| 53 | // tryRecoverReasoningReplay400 is the streamWithSamplingRecovery branch for a |
| 54 | // thinking-400: the frozen history's replayed reasoning is stale for this |
| 55 | // provider, so repair the projection once and retry; every other 400 falls |
| 56 | // through to the terminal path. On a repair the speculative attempt's buffered |
| 57 | // events are discarded and the attempt is audited before the caller replays. |
| 58 | func (a *Agent) tryRecoverReasoningReplay400(streamSink *deferredStreamSink, frozen samplingRequest, attemptID string, attempt int, err error, budget *reasoningReplayRecoveryBudget) (samplingRequest, bool) { |
| 59 | next, ok := a.recoverReasoningReplay400(frozen, err, budget) |
| 60 | if !ok { |
| 61 | return samplingRequest{}, false |
| 62 | } |
| 63 | if streamSink != nil { |
| 64 | streamSink.Discard() |
| 65 | } |
| 66 | a.emitStreamAttempt(attemptID, event.StreamAttemptDiscard, attempt, "reasoning_replay_400", err) |
| 67 | event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryReasoningReplay400Detected}) |
| 68 | return next, true |
| 69 | } |
| 70 | |
| 71 | // activateReasoningReplayStrongProjection records the repaired history prefix. |
| 72 | // Messages appended after it keep their normal reasoning/tool replay. |
| 73 | func (a *Agent) activateReasoningReplayStrongProjection(budget reasoningReplayRecoveryBudget) { |
| 74 | if a == nil { |
| 75 | return |
| 76 | } |
| 77 | a.sess.reasoningReplayStrongProjection = budget.cutoff |
| 78 | a.sess.reasoningReplayStrongProjectionAnchor = budget.anchor |
| 79 | kind := event.ProtocolRecoveryReasoningReplay400Recovered |
| 80 | if budget.local { |
| 81 | kind = event.ProtocolRecoveryHistoryRepaired |
| 82 | } |
| 83 | event.RecordProtocolRecovery(a.svc.sink, event.ProtocolRecoveryAudit{Kind: kind}) |
| 84 | a.emitReasoningReplayRepairNotice() |
| 85 | } |
| 86 | |
| 87 | func (a *Agent) emitReasoningReplayRepairNotice() { |
| 88 | if a == nil || a.svc.sink == nil { |
| 89 | return |
| 90 | } |
| 91 | a.svc.sink.Emit(event.Event{ |
| 92 | Kind: event.Notice, |
| 93 | Level: event.LevelWarn, |
| 94 | Code: event.NoticeCodeReasoningReplayRepair, |
| 95 | Text: i18n.M.ReasoningReplayRepair, |
| 96 | Detail: "reasoning replay recovery regenerated the response once from a repaired history with completed-tool facts preserved", |
| 97 | }) |
| 98 | } |
| 99 |