| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | const compactionBudget = 5 * time.Minute |
| 11 | |
| 12 | // SummaryError keeps the failure identity without changing the wire contract. |
| 13 | type SummaryError struct { |
| 14 | Code string |
| 15 | Cause error |
| 16 | } |
| 17 | |
| 18 | func (e *SummaryError) Error() string { return fmt.Sprintf("%s: %v", e.Code, e.Cause) } |
| 19 | func (e *SummaryError) Unwrap() error { return e.Cause } |
| 20 | |
| 21 | var errSummaryEmpty = errors.New("summarizer returned empty output") |
| 22 | var errSummaryBudget = &SummaryError{Code: "summary_budget_exceeded", Cause: context.DeadlineExceeded} |
| 23 | |
| 24 | func summaryError(err error) error { |
| 25 | if err == nil { |
| 26 | return nil |
| 27 | } |
| 28 | var typed *SummaryError |
| 29 | var persistence *compactionPersistenceError |
| 30 | if errors.As(err, &typed) || errors.As(err, &persistence) || errors.Is(err, context.Canceled) { |
| 31 | return err |
| 32 | } |
| 33 | var code string |
| 34 | switch { |
| 35 | case errors.Is(err, errSummaryEmpty): |
| 36 | code = "summary_empty" |
| 37 | case errors.Is(err, errSummaryOutputTruncated): |
| 38 | code = "summary_output_truncated" |
| 39 | case errors.Is(err, errCheckpointRejected): |
| 40 | code = "summary_no_reduction" |
| 41 | case errors.Is(err, errCompressStaleContext): |
| 42 | code = "summary_context_changed" |
| 43 | default: |
| 44 | return err |
| 45 | } |
| 46 | return &SummaryError{Code: code, Cause: err} |
| 47 | } |
| 48 | |
| 49 | // Only the summary request owner may classify an otherwise untyped provider |
| 50 | // failure. Entry-point validation, extensions, and persistence keep their errors. |
| 51 | func summaryRequestError(ctx context.Context, err error) error { |
| 52 | err = summaryError(compactionError(ctx, err)) |
| 53 | var typed *SummaryError |
| 54 | var persistence *compactionPersistenceError |
| 55 | if err == nil || errors.As(err, &typed) || errors.As(err, &persistence) || errors.Is(err, context.Canceled) { |
| 56 | return err |
| 57 | } |
| 58 | return &SummaryError{Code: "summary_provider_error", Cause: err} |
| 59 | } |
| 60 | |
| 61 | type compactionRunKey struct{} |
| 62 | type compactionRun struct{ work context.Context } |
| 63 | |
| 64 | func currentCompactionRun(ctx context.Context) *compactionRun { |
| 65 | run, _ := ctx.Value(compactionRunKey{}).(*compactionRun) |
| 66 | return run |
| 67 | } |
| 68 | |
| 69 | // Every nested summary, replan, and chunk inherits the same deadline. The |
| 70 | // caller's ordinary answer context is not cancelled by this work budget. |
| 71 | func (a *Agent) beginCompactionRun(parent context.Context) (context.Context, func(error) error) { |
| 72 | if parent == nil { |
| 73 | parent = context.Background() |
| 74 | } |
| 75 | if run := currentCompactionRun(parent); run != nil { |
| 76 | return run.work, func(err error) error { return err } |
| 77 | } |
| 78 | ctx, cancel := context.WithTimeoutCause(parent, compactionBudget, errSummaryBudget) |
| 79 | run := &compactionRun{} |
| 80 | ctx = context.WithValue(ctx, compactionRunKey{}, run) |
| 81 | run.work = ctx |
| 82 | return ctx, func(err error) error { |
| 83 | defer cancel() |
| 84 | return compactionError(ctx, err) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func compactionError(ctx context.Context, err error) error { |
| 89 | if err == nil { |
| 90 | return nil |
| 91 | } |
| 92 | var persistence *compactionPersistenceError |
| 93 | if errors.As(err, &persistence) { |
| 94 | return err |
| 95 | } |
| 96 | if errors.Is(err, context.DeadlineExceeded) && errors.Is(context.Cause(ctx), errSummaryBudget) && !errors.Is(err, errSummaryBudget) { |
| 97 | if err == context.DeadlineExceeded { //nolint:errorlint // Only the bare sentinel can be replaced without discarding an enclosing error. |
| 98 | return errSummaryBudget |
| 99 | } |
| 100 | // Keep any enclosing recovery/error identity, even when a work deadline |
| 101 | // needs its budget classification added. An expired run alone is not |
| 102 | // evidence that a later request-preparation error came from compaction. |
| 103 | return fmt.Errorf("%w: %w", err, errSummaryBudget) |
| 104 | } |
| 105 | return err |
| 106 | } |
| 107 | |
| 108 | type compactionPersistenceError struct{ error } |
| 109 | |
| 110 | func (e *compactionPersistenceError) Unwrap() error { return e.error } |
| 111 |