| 1 | package boot |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | "sync" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | const deepSeekOverflowBody = `{"error":{"message":"This model's maximum context length is %d tokens. However, you requested %d tokens (%d in the messages, %d in the completion). Please reduce the length of the messages or completion.","type":"invalid_request_error","param":null,"code":"invalid_request_error"}}` |
| 16 | |
| 17 | // denseSummaryProvider drives a read loop and counts summary requests at three |
| 18 | // characters per token, denser than the estimator's cold four, while sampling |
| 19 | // requests count at four. That is the #9818 shape: the ordinary turn fits, the |
| 20 | // summary of the same history does not, and the rejection is DeepSeek's 400. |
| 21 | type denseSummaryProvider struct { |
| 22 | mu sync.Mutex |
| 23 | window int |
| 24 | rounds int |
| 25 | maxRounds int |
| 26 | summaries []int // dense token count of every summary request, in order |
| 27 | samplings []int // wire characters of every sampling request, in order |
| 28 | overflows int |
| 29 | } |
| 30 | |
| 31 | func (p *denseSummaryProvider) Name() string { return "boot-dense-summary" } |
| 32 | |
| 33 | func (p *denseSummaryProvider) ContextBudgetPolicy() provider.ContextBudgetPolicy { |
| 34 | return provider.ContextBudgetPolicy{ |
| 35 | WindowMode: provider.ContextWindowShared, AutoOutputTokens: 8192, MaxOutputTokens: 8192, |
| 36 | LimitMode: provider.OutputLimitOmitWhenSafe, |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func requestChars(req provider.Request) int { |
| 41 | n := 0 |
| 42 | for _, m := range req.Messages { |
| 43 | n += len(m.Content) + len(m.ReasoningContent) |
| 44 | for _, tc := range m.ToolCalls { |
| 45 | n += len(tc.Name) + len(tc.Arguments) |
| 46 | } |
| 47 | } |
| 48 | for _, schema := range req.Tools { |
| 49 | n += len(schema.Name) + len(schema.Description) + len(schema.Parameters) |
| 50 | } |
| 51 | return n |
| 52 | } |
| 53 | |
| 54 | func isCompactionRequest(req provider.Request) bool { |
| 55 | return len(req.Messages) > 0 && strings.Contains(req.Messages[len(req.Messages)-1].Content, "Compact the preceding conversation prefix") |
| 56 | } |
| 57 | |
| 58 | func (p *denseSummaryProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { |
| 59 | p.mu.Lock() |
| 60 | defer p.mu.Unlock() |
| 61 | chars := requestChars(req) |
| 62 | if isCompactionRequest(req) { |
| 63 | prompt := chars / 3 |
| 64 | p.summaries = append(p.summaries, prompt) |
| 65 | completion := req.MaxTokens |
| 66 | if completion <= 0 { |
| 67 | completion = 8192 |
| 68 | } |
| 69 | if prompt+completion > p.window { |
| 70 | p.overflows++ |
| 71 | body := fmt.Sprintf(deepSeekOverflowBody, p.window, prompt+completion, prompt, completion) |
| 72 | limit := provider.ParseContextLimitError(&provider.APIError{Provider: p.Name(), Status: 400, Body: body}) |
| 73 | if limit == nil { |
| 74 | return nil, fmt.Errorf("DeepSeek overflow body did not parse: %s", body) |
| 75 | } |
| 76 | return nil, limit |
| 77 | } |
| 78 | return streamChunks( |
| 79 | provider.Chunk{Type: provider.ChunkText, Text: "- goal: read big.txt repeatedly\n- pending: keep reading"}, |
| 80 | provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: prompt, CompletionTokens: 12, TotalTokens: prompt + 12, RequestCount: 1}}, |
| 81 | provider.Chunk{Type: provider.ChunkDone}, |
| 82 | ), nil |
| 83 | } |
| 84 | p.samplings = append(p.samplings, chars) |
| 85 | prompt := chars / 4 |
| 86 | usage := &provider.Usage{PromptTokens: prompt, CompletionTokens: 10, TotalTokens: prompt + 10, RequestCount: 1} |
| 87 | if p.rounds >= p.maxRounds { |
| 88 | return streamChunks( |
| 89 | provider.Chunk{Type: provider.ChunkText, Text: "Done reading."}, |
| 90 | provider.Chunk{Type: provider.ChunkUsage, Usage: usage}, |
| 91 | provider.Chunk{Type: provider.ChunkDone}, |
| 92 | ), nil |
| 93 | } |
| 94 | p.rounds++ |
| 95 | return streamChunks( |
| 96 | provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ |
| 97 | ID: fmt.Sprintf("read-%d", p.rounds), Name: "read_file", Arguments: fmt.Sprintf(`{"path":"file-%d.txt"}`, p.rounds), |
| 98 | }}, |
| 99 | provider.Chunk{Type: provider.ChunkUsage, Usage: usage}, |
| 100 | provider.Chunk{Type: provider.ChunkDone}, |
| 101 | ), nil |
| 102 | } |
| 103 | |
| 104 | func streamChunks(items ...provider.Chunk) <-chan provider.Chunk { |
| 105 | ch := make(chan provider.Chunk, len(items)) |
| 106 | for _, item := range items { |
| 107 | ch <- item |
| 108 | } |
| 109 | close(ch) |
| 110 | return ch |
| 111 | } |
| 112 | |
| 113 | // TestEffectSummaryOverflowShrinksNextSummaryThroughRealBuild pins the |
| 114 | // overflow feedback at its final boundary: when the provider rejects the |
| 115 | // summary request itself, the next summary request that reaches the provider |
| 116 | // is strictly smaller and the tool loop completes instead of dead-ending. |
| 117 | func TestEffectSummaryOverflowShrinksNextSummaryThroughRealBuild(t *testing.T) { |
| 118 | isolateConfigHome(t) |
| 119 | dir := robustTempDir(t) |
| 120 | t.Chdir(dir) |
| 121 | |
| 122 | // Results stay under the prune threshold, unique per file, and complete, so |
| 123 | // neither pruning, duplicate-result folding, nor the incomplete-read strategy |
| 124 | // can relieve pressure: only a summary can. |
| 125 | rec := &denseSummaryProvider{window: 40_000, maxRounds: 30} |
| 126 | provider.Register("boot-dense-summary", func(provider.Config) (provider.Provider, error) { |
| 127 | return rec, nil |
| 128 | }) |
| 129 | for i := 1; i <= rec.maxRounds; i++ { |
| 130 | var body strings.Builder |
| 131 | for line := range 120 { |
| 132 | fmt.Fprintf(&body, "file %d line %d: the quick brown fox jumps over the lazy dog\n", i, line) |
| 133 | } |
| 134 | writeFile(t, dir, fmt.Sprintf("file-%d.txt", i), body.String()) |
| 135 | } |
| 136 | writeFile(t, dir, "reasonix.toml", ` |
| 137 | default_model = "test-model" |
| 138 | |
| 139 | [agent] |
| 140 | system_prompt = "BASE" |
| 141 | |
| 142 | [environment] |
| 143 | enabled = false |
| 144 | |
| 145 | [[providers]] |
| 146 | name = "test-model" |
| 147 | kind = "boot-dense-summary" |
| 148 | model = "x" |
| 149 | context_window = 40000 |
| 150 | `) |
| 151 | |
| 152 | ctrl, err := Build(context.Background(), Options{Sink: event.Discard}) |
| 153 | if err != nil { |
| 154 | t.Fatalf("Build: %v", err) |
| 155 | } |
| 156 | defer ctrl.Close() |
| 157 | |
| 158 | runCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| 159 | defer cancel() |
| 160 | if err := ctrl.Run(runCtx, "read every file-N.txt until you are told to stop"); err != nil { |
| 161 | t.Fatalf("Run: %v", err) |
| 162 | } |
| 163 | |
| 164 | rec.mu.Lock() |
| 165 | defer rec.mu.Unlock() |
| 166 | if rec.rounds < rec.maxRounds { |
| 167 | t.Fatalf("tool loop stopped after %d of %d rounds; the run dead-ended", rec.rounds, rec.maxRounds) |
| 168 | } |
| 169 | if rec.overflows == 0 { |
| 170 | t.Fatalf("no summary request overflowed; the fixture did not reproduce the dense-summary shape (summaries=%v samplings=%v)", rec.summaries, rec.samplings) |
| 171 | } |
| 172 | for i := 1; i < len(rec.summaries); i++ { |
| 173 | if rec.summaries[i-1]+8192 > rec.window && rec.summaries[i] >= rec.summaries[i-1] { |
| 174 | t.Fatalf("summary request after an overflow did not shrink: %v", rec.summaries) |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 |