From b3a8f40bcdb10f68bcf359361067670313b92627 Mon Sep 17 00:00:00 2001 From: Linearleaf Date: Mon, 7 Sep 2026 14:10:15 +0800 Subject: [PATCH 1/2] fix(agent,provider): un-stick compaction on thinking-model summaries and unnumbered overflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compaction failure shapes observed on a 2M-token session (fork开发), where pressure-triggered summary retries never converged and the turn died with an HTTP 400 overflow: - summarize() accumulated only ChunkText, so a thinking-model summary (DeepSeek vision SKUs put the whole briefing in reasoning_content with an empty content block) returned 'summarizer returned empty output' and the chunked fragment fallback died on the same check forever (observed: fragment 2/14). Surface a pure reasoning-only summary (clamped), mirroring the boundedllm #9679 treatment; a turn that also attempted tool calls keeps the empty-output rejection — that reasoning is private chain-of-thought, not digest material. - ParseContextLimitError could not recognize provider overflows that carry no token numbers: Zhipu GLM 1261 'Prompt exceeds max length' matched none of the numeric regexes and no JSON token field, so AsContextLimitError returned nil, the chunked compaction fallback never triggered, and the uncapped request failed transparently on every retry. Trust the provider-confirmed overflow with an unknown window (zero token fields); consumers already treat 0 as 'learn nothing, fall back to the configured window'. Guards: TestSummarizerReasoningOnlyIsSurfacedNotEmptied, TestParseContextLimitErrorGLMUnnumbered1261; existing TestSummaryCollectorRejectsEmptyAndLengthLimitedOutput shape intent (reasoning + tool call = reject) preserved. --- internal/agent/compact.go | 35 ++++++++++++++++--- .../agent/compact_summary_failure_test.go | 19 ++++++++++ internal/agent/compact_test.go | 20 +++++++---- internal/provider/context_limit.go | 33 +++++++++++++++++ internal/provider/context_limit_test.go | 25 +++++++++++++ 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/internal/agent/compact.go b/internal/agent/compact.go index 37ba5663c5..637cef7384 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -21,10 +21,15 @@ const ( defaultCompactRatio = 0.80 // sole automatic maintenance trigger (new configs) recentTailBudgetRatio = 0.16 // recent verbatim tail as a fraction of the window summaryOutputMaxTokens = 8192 // max digest output; further clipped by remaining candidate space - minRecentKeep = 2 // never keep fewer recent messages than this - minCompactMessages = 2 // skip compaction below this many compactable messages - fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate - protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates + + // summaryReasoningMaxBytes clamps a surfaced reasoning-only summary + // (~8k tokens of bytes), matching the summaryOutputMaxTokens envelope. + summaryReasoningMaxBytes = 32768 + + minRecentKeep = 2 // never keep fewer recent messages than this + minCompactMessages = 2 // skip compaction below this many compactable messages + fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate + protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates ) var ( @@ -435,6 +440,8 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru // Unblock on timeout if the stream stalls while open. var b strings.Builder + var reasoning strings.Builder + toolCalls := 0 for { select { case <-ctx.Done(): @@ -446,13 +453,31 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru } s := strings.TrimSpace(b.String()) if s == "" { - return "", usage, fmt.Errorf("summarizer returned empty output") + // Thinking-mode providers (e.g. DeepSeek vision SKUs) may put the + // whole answer in reasoning_content with an empty content block + // (#9679 follow-up: same shape boundedllm learned to surface). + // Surface a pure reasoning-only summary so the turn is not misread + // as "empty output" and retried forever. A turn that also tried to + // call tools did not produce a briefing; keep rejecting that shape + // (the reasoning is private chain-of-thought, not digest material). + r := strings.TrimSpace(reasoning.String()) + if r == "" || toolCalls > 0 { + return "", usage, fmt.Errorf("summarizer returned empty output") + } + if len(r) > summaryReasoningMaxBytes { + r = r[:summaryReasoningMaxBytes] + } + return r, usage, nil } return s, usage, nil } switch chunk.Type { case provider.ChunkText: b.WriteString(chunk.Text) + case provider.ChunkReasoning: + reasoning.WriteString(chunk.Text) + case provider.ChunkToolCall: + toolCalls++ case provider.ChunkUsage: usage = chunk.Usage case provider.ChunkError: diff --git a/internal/agent/compact_summary_failure_test.go b/internal/agent/compact_summary_failure_test.go index e6b52ff5c2..6b71afbdb7 100644 --- a/internal/agent/compact_summary_failure_test.go +++ b/internal/agent/compact_summary_failure_test.go @@ -107,6 +107,25 @@ func TestSummarizerCancellationAtOverflowPropagatesWithoutFallback(t *testing.T) } } +// Thinking-mode providers (DeepSeek vision SKUs) may answer the summary +// request with reasoning_content only and an empty content block. The +// summarizer must surface the reasoning instead of failing with "summarizer +// returned empty output" and retrying forever (observed on a 2M-token session: +// chunked fallback reached fragment 2/14 and died on the same empty-output +// check). +func TestSummarizerReasoningOnlyIsSurfacedNotEmptied(t *testing.T) { + sess := foldableSessionOverForce(6) + a := agentOverForce(t, &fakeProvider{reasoningReply: "- kept: alpha constraint\n- kept: beta file path"}, sess) + before := estimateMessagesTokens(provider.ModelMessages(sess.Messages)) + + if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { + t.Fatalf("prepare with reasoning-only summary = %v, want applied fold", err) + } + if after := projectionTokens(a); after == 0 || after >= before { + t.Fatalf("reasoning-only summary installed projection tokens=%d (source=%d)", after, before) + } +} + // Overflow is the trigger that reports ErrCompactionRequired, so it is where a // failed summary turns into "context exceeds provider limit and compaction // failed" without installing fabricated fallback content. diff --git a/internal/agent/compact_test.go b/internal/agent/compact_test.go index 26b89a2791..c731cd7617 100644 --- a/internal/agent/compact_test.go +++ b/internal/agent/compact_test.go @@ -26,11 +26,12 @@ func prepareForObservedUsage(a *Agent, ctx context.Context, usage *provider.Usag // fakeProvider returns a fixed reply and records the messages it was asked to // complete, so tests can drive summarization without a network call. type fakeProvider struct { - reply string - promptTokens int - got []provider.Message - streamErr error // when set, Stream emits a ChunkError instead of the reply - hang bool // when true, Stream returns a channel that never sends or closes + reply string + reasoningReply string // set (with empty reply) to emit ChunkReasoning: thinking-model shape + promptTokens int + got []provider.Message + streamErr error // when set, Stream emits a ChunkError instead of the reply + hang bool // when true, Stream returns a channel that never sends or closes } func (f *fakeProvider) Name() string { return "fake" } @@ -50,7 +51,14 @@ func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan p close(ch) return ch, nil } - ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply} + // Default stays byte-identical to the historical shape: always emit + // ChunkText (even empty). Only an explicit reasoningReply with no reply + // switches to the thinking-model reasoning-only shape. + if f.reply == "" && f.reasoningReply != "" { + ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: f.reasoningReply} + } else { + ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply} + } if f.promptTokens > 0 { ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: f.promptTokens, TotalTokens: f.promptTokens}} } diff --git a/internal/provider/context_limit.go b/internal/provider/context_limit.go index 26e44e5e86..bf86767b80 100644 --- a/internal/provider/context_limit.go +++ b/internal/provider/context_limit.go @@ -222,11 +222,24 @@ func ParseContextLimitError(apiErr *APIError) *ContextLimitError { } else if w, r, p, c, ok := parseContextLimitText(message); ok { window, requested, prompt, completion = w, r, p, c } else { + // Providers that report a bare overflow with no token numbers + // (Zhipu GLM: {"error":{"code":"1261","message":"Prompt exceeds max + // length"}}). The overflow is provider-confirmed; the window stays + // unknown (0), which every consumer treats as "learn nothing, fall + // back to the configured window" — and, critically, chunked + // compaction fallback triggers on this error instead of letting an + // oversized request fail transparently forever. + if isUnnumberedPromptTooLong(message, apiErr.Body) { + return &ContextLimitError{APIError: apiErr} + } return nil } } if !contextLimitInvariant(window, requested, prompt, completion) && !(window > 0 && requested > window && prompt > 0) { + if isUnnumberedPromptTooLong(message, apiErr.Body) { + return &ContextLimitError{APIError: apiErr} + } return nil } if requested <= 0 { @@ -241,6 +254,26 @@ func ParseContextLimitError(apiErr *APIError) *ContextLimitError { } } +// isUnnumberedPromptTooLong matches provider overflow errors that carry no +// token numbers at all. Canonical shape — Zhipu GLM 1261: +// +// {"error":{"code":"1261","message":"Prompt exceeds max length"}} +// +// The message (or the whole body, when the JSON shape differs) is matched +// case-insensitively; code 1261 is not matched directly so sibling GLM codes +// that reuse the message stay covered and numeric codes never false-positive. +func isUnnumberedPromptTooLong(message, body string) bool { + for _, s := range []string{message, body} { + if s == "" { + continue + } + if strings.Contains(strings.ToLower(s), "prompt exceeds max length") { + return true + } + } + return false +} + // AsContextLimitError unwraps err to a trusted overflow, if any. func AsContextLimitError(err error) *ContextLimitError { var limit *ContextLimitError diff --git a/internal/provider/context_limit_test.go b/internal/provider/context_limit_test.go index ec38555224..e937e22f5e 100644 --- a/internal/provider/context_limit_test.go +++ b/internal/provider/context_limit_test.go @@ -25,6 +25,31 @@ func TestParseContextLimitErrorNumericJSON(t *testing.T) { } } +func TestParseContextLimitErrorGLMUnnumbered1261(t *testing.T) { + // Zhipu GLM reports a bare overflow with no token numbers (observed on a + // 2M-token session: glm-cn 400 {"code":"1261","message":"Prompt exceeds max + // length"}). It must be trusted as a context-limit error with an unknown + // window so chunked compaction fallback triggers instead of the oversized + // request failing transparently on every retry. + body := `{"error":{"code":"1261","message":"Prompt exceeds max length"}}` + got := ParseContextLimitError(&APIError{Status: 400, Body: body}) + if got == nil { + t.Fatal("GLM 1261 must be trusted as a context-limit error") + } + if got.WindowTokens != 0 || got.RequestedTokens != 0 || got.PromptTokens != 0 || got.CompletionTokens != 0 { + t.Fatalf("unnumbered overflow must carry zero token fields, got %+v", got) + } + if errors.Unwrap(got) == nil { + t.Fatal("Unwrap must return the original APIError") + } + if ParseContextLimitError(&APIError{Status: 400, Body: `{"error":{"message":"prompt exceeds max length"}}`}) == nil { + t.Fatal("case-insensitive message variant must be trusted") + } + if ParseContextLimitError(&APIError{Status: 401, Body: `{"error":{"code":"1261","message":"Prompt exceeds max length"}}`}) != nil { + t.Fatal("401 must not be treated as a context limit") + } +} + func TestParseContextLimitErrorRejectsMalformedAndNonContext(t *testing.T) { if ParseContextLimitError(&APIError{Status: 400, Body: `{"error":{"message":"unpaired tool_calls"}}`}) != nil { t.Fatal("non-context 400 must stay unparsed") From 41594e920a4bfe06606f26b7315ff54a45a095df Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:27:13 +0800 Subject: [PATCH 2/2] fix(agent,control): finish unnumbered-overflow recovery and harden reasoning summaries Problem: an overflow parsed without token numbers (Zhipu GLM 1261) reached the sampling recovery path, which answered it by resending the same prompt under a clipped output cap and then failed; the user-facing error quoted "prompt 0 + completion 0 = 0 tokens, window 0". The reasoning-only summary clamp sliced bytes and could cut a multi-byte rune, an opened-but-unfinished tool call did not count as a tool attempt, and three comment blocks failed repolint's essay rule. Root cause: consumers of ContextLimitError assumed positive token fields, and the new clamp used a byte slice instead of the package's rune-safe helper. Fix: recovery treats a zero-field overflow as physically over the window and goes straight to overflow compaction; the error message falls through to the generic 400 text when no numbers are known; the clamp uses truncateUTF8Bytes; ChunkToolCallStart counts as a tool attempt; comments trimmed to the limit. Verification: go vet, repolint, golangci-lint clean; go test ./internal/agent ./internal/provider/... ./internal/control pass, including new TestUnnumberedContextLimitSkipsIdenticalRetry, TestSummarizerReasoningWithToolCallStaysEmpty, TestSummarizerReasoningClampKeepsValidUTF8, and the zero-field errmsg case. --- internal/agent/compact.go | 17 +++------ .../agent/compact_summary_failure_test.go | 32 ++++++++++++++++ internal/agent/compact_test.go | 4 ++ internal/agent/context_recovery.go | 5 +++ internal/agent/context_recovery_test.go | 37 +++++++++++++++++++ internal/control/errmsg.go | 4 +- internal/control/errmsg_test.go | 10 +++++ internal/provider/context_limit.go | 10 ++--- internal/provider/context_limit_test.go | 8 ++-- 9 files changed, 102 insertions(+), 25 deletions(-) diff --git a/internal/agent/compact.go b/internal/agent/compact.go index 637cef7384..a94f7d5a5d 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -453,21 +453,14 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru } s := strings.TrimSpace(b.String()) if s == "" { - // Thinking-mode providers (e.g. DeepSeek vision SKUs) may put the - // whole answer in reasoning_content with an empty content block - // (#9679 follow-up: same shape boundedllm learned to surface). - // Surface a pure reasoning-only summary so the turn is not misread - // as "empty output" and retried forever. A turn that also tried to - // call tools did not produce a briefing; keep rejecting that shape - // (the reasoning is private chain-of-thought, not digest material). + // Thinking providers may answer with reasoning_content only. Surface + // it as the briefing unless the turn also reached for tools: that + // reasoning is private chain-of-thought, not digest material. r := strings.TrimSpace(reasoning.String()) if r == "" || toolCalls > 0 { return "", usage, fmt.Errorf("summarizer returned empty output") } - if len(r) > summaryReasoningMaxBytes { - r = r[:summaryReasoningMaxBytes] - } - return r, usage, nil + return truncateUTF8Bytes(r, summaryReasoningMaxBytes), usage, nil } return s, usage, nil } @@ -476,7 +469,7 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru b.WriteString(chunk.Text) case provider.ChunkReasoning: reasoning.WriteString(chunk.Text) - case provider.ChunkToolCall: + case provider.ChunkToolCall, provider.ChunkToolCallStart: toolCalls++ case provider.ChunkUsage: usage = chunk.Usage diff --git a/internal/agent/compact_summary_failure_test.go b/internal/agent/compact_summary_failure_test.go index 6b71afbdb7..d5dc174077 100644 --- a/internal/agent/compact_summary_failure_test.go +++ b/internal/agent/compact_summary_failure_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" "reasonix/internal/event" "reasonix/internal/provider" @@ -126,6 +127,37 @@ func TestSummarizerReasoningOnlyIsSurfacedNotEmptied(t *testing.T) { } } +// A reasoning-only reply that also opened a tool call is not a briefing: the +// empty-output rejection must survive, and an opened call counts even when +// the stream never completed it. +func TestSummarizerReasoningWithToolCallStaysEmpty(t *testing.T) { + sess := foldableSessionOverForce(6) + a := agentOverForce(t, &fakeProvider{reasoningReply: "let me call a tool first", reasoningTool: true}, sess) + + err := prepareContext(context.Background(), a, CompactionTriggerOverflow) + if !errors.Is(err, ErrCompactionRequired) || !strings.Contains(err.Error(), "summarizer returned empty output") { + t.Fatalf("prepare = %v, want the empty-output rejection under ErrCompactionRequired", err) + } + if after := projectionTokens(a); after != 0 { + t.Fatalf("reasoning with a tool call installed projection tokens=%d", after) + } +} + +// The reasoning clamp cuts on rune boundaries so a CJK briefing stays valid +// UTF-8 for the provider request that replays the digest. +func TestSummarizerReasoningClampKeepsValidUTF8(t *testing.T) { + sess := foldableSessionOverForce(6) + a := agentOverForce(t, &fakeProvider{reasoningReply: strings.Repeat("上下文摘要要点。", 3000)}, sess) + + summary, _, err := a.summarize(context.Background(), sess.Messages[1:], "") + if err != nil { + t.Fatalf("summarize = %v", err) + } + if len(summary) > summaryReasoningMaxBytes || !utf8.ValidString(summary) { + t.Fatalf("clamped reasoning is %d bytes valid=%v, want <= %d bytes of valid UTF-8", len(summary), utf8.ValidString(summary), summaryReasoningMaxBytes) + } +} + // Overflow is the trigger that reports ErrCompactionRequired, so it is where a // failed summary turns into "context exceeds provider limit and compaction // failed" without installing fabricated fallback content. diff --git a/internal/agent/compact_test.go b/internal/agent/compact_test.go index c731cd7617..7955c71f8d 100644 --- a/internal/agent/compact_test.go +++ b/internal/agent/compact_test.go @@ -28,6 +28,7 @@ func prepareForObservedUsage(a *Agent, ctx context.Context, usage *provider.Usag type fakeProvider struct { reply string reasoningReply string // set (with empty reply) to emit ChunkReasoning: thinking-model shape + reasoningTool bool // with reasoningReply: also open a tool call, the shape that stays rejected promptTokens int got []provider.Message streamErr error // when set, Stream emits a ChunkError instead of the reply @@ -56,6 +57,9 @@ func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan p // switches to the thinking-model reasoning-only shape. if f.reply == "" && f.reasoningReply != "" { ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: f.reasoningReply} + if f.reasoningTool { + ch <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file"}} + } } else { ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply} } diff --git a/internal/agent/context_recovery.go b/internal/agent/context_recovery.go index 7008826ea0..0416586134 100644 --- a/internal/agent/context_recovery.go +++ b/internal/agent/context_recovery.go @@ -35,6 +35,11 @@ func (a *Agent) recoverContextLimit(ctx context.Context, frozen samplingRequest, prompt = a.estimatedRequestTokens(frozen.req) } physical := window - prompt - outputBudgetReserve + // An overflow without token numbers cannot size a retry: the estimate that + // admitted the request is the number the provider just rejected. + if limit.PromptTokens <= 0 && limit.WindowTokens <= 0 { + physical = 0 + } if physical > 0 && budget.retries == 0 { next := freezeProviderRequest(frozen.req) next.MaxTokens = physical diff --git a/internal/agent/context_recovery_test.go b/internal/agent/context_recovery_test.go index 87667384ce..abe5026fc0 100644 --- a/internal/agent/context_recovery_test.go +++ b/internal/agent/context_recovery_test.go @@ -146,6 +146,43 @@ func TestContextLimitRecoveryChangesOnlyOutputField(t *testing.T) { } } +// An overflow without token numbers (Zhipu GLM 1261) must not be answered +// with the same prompt under a clipped output cap: the estimate that admitted +// the request is exactly what the provider rejected, so recovery goes straight +// to overflow compaction and retries the rebuilt request. +func TestUnnumberedContextLimitSkipsIdenticalRetry(t *testing.T) { + prov := &scriptedBudgetProvider{ + policy: provider.ContextBudgetPolicy{ + WindowMode: provider.ContextWindowShared, AutoOutputTokens: 384_000, + MaxOutputTokens: 384_000, LimitMode: provider.OutputLimitOmitWhenSafe, + }, + errs: []error{&provider.ContextLimitError{APIError: &provider.APIError{ + Provider: "glm", Status: 400, Body: `{"error":{"code":"1261","message":"Prompt exceeds max length"}}`, + }}}, + } + a := newBudgetAgent(t, prov) + a.sess.conversation.Replace(foldableSessionOverForce(6).Messages) + + got := a.streamWithSamplingRecovery(context.Background(), 1) + if got.err != nil { + t.Fatalf("recovery failed: %v", got.err) + } + prov.mu.Lock() + defer prov.mu.Unlock() + if len(prov.reqs) != 3 { + t.Fatalf("requests = %d, want rejected sampling, one summary, and the rebuilt sampling", len(prov.reqs)) + } + if !requestContains(prov.reqs[1], "Compact the preceding conversation prefix") { + t.Fatal("second request must be the overflow compaction summary, not a retry of the rejected prompt") + } + if sameProviderRequestExceptMaxTokens(prov.reqs[0], prov.reqs[2]) { + t.Fatal("the retried request must be rebuilt on the compacted view, not the rejected prompt") + } + if a.lastAdmission().LastRecovery != contextRecoveryCompacted { + t.Fatalf("last recovery = %s, want compacted", a.lastAdmission().LastRecovery) + } +} + func TestContextLimitRecoveryPublishesUnknownGatewayBudget(t *testing.T) { limit := &provider.ContextLimitError{ APIError: &provider.APIError{Provider: "compatible", Status: 400, Body: "context"}, diff --git a/internal/control/errmsg.go b/internal/control/errmsg.go index 12500dc5c3..4c754c06f8 100644 --- a/internal/control/errmsg.go +++ b/internal/control/errmsg.go @@ -32,7 +32,9 @@ func explainError(err error) error { if provider.IsConnReset(err) { return fmt.Errorf("model stream disconnected before completion after retry attempts: %s. Check the provider/proxy connection, then retry or ask Reasonix to continue", err.Error()) } - if limit := provider.AsContextLimitError(err); limit != nil { + // An overflow without token numbers has nothing to quote; the generic 400 + // branch below keeps the provider's own reason instead of zeros. + if limit := provider.AsContextLimitError(err); limit != nil && limit.WindowTokens > 0 { msg := fmt.Sprintf(i18n.M.ProviderErrContextOverflowFmt, limit.PromptTokens, limit.CompletionTokens, limit.RequestedTokens, limit.WindowTokens) if reason := apiErrorReason(limit.APIError); reason != "" { return fmt.Errorf("%s\n%s", msg, reason) diff --git a/internal/control/errmsg_test.go b/internal/control/errmsg_test.go index 40982e7e46..9be8b2754b 100644 --- a/internal/control/errmsg_test.go +++ b/internal/control/errmsg_test.go @@ -94,6 +94,16 @@ func TestExplainError(t *testing.T) { t.Errorf("context overflow should name numbers and recovery, got %q", limit.Error()) } + unnumbered := explainError(&provider.ContextLimitError{ + APIError: &provider.APIError{Provider: "glm", Status: 400, Body: `{"error":{"code":"1261","message":"Prompt exceeds max length"}}`}, + }) + if strings.Contains(unnumbered.Error(), fmt.Sprintf(i18n.M.ProviderErrContextOverflowFmt, 0, 0, 0, 0)) { + t.Errorf("an overflow without token numbers must not quote zeros, got %q", unnumbered.Error()) + } + if !strings.Contains(unnumbered.Error(), i18n.M.ProviderErrBadRequest) || !strings.Contains(unnumbered.Error(), "Prompt exceeds max length") { + t.Errorf("an overflow without token numbers should keep the provider reason, got %q", unnumbered.Error()) + } + toolSchema := explainError(&provider.APIError{ Provider: "mimo", Status: 400, diff --git a/internal/provider/context_limit.go b/internal/provider/context_limit.go index bf86767b80..6aa011d268 100644 --- a/internal/provider/context_limit.go +++ b/internal/provider/context_limit.go @@ -222,13 +222,9 @@ func ParseContextLimitError(apiErr *APIError) *ContextLimitError { } else if w, r, p, c, ok := parseContextLimitText(message); ok { window, requested, prompt, completion = w, r, p, c } else { - // Providers that report a bare overflow with no token numbers - // (Zhipu GLM: {"error":{"code":"1261","message":"Prompt exceeds max - // length"}}). The overflow is provider-confirmed; the window stays - // unknown (0), which every consumer treats as "learn nothing, fall - // back to the configured window" — and, critically, chunked - // compaction fallback triggers on this error instead of letting an - // oversized request fail transparently forever. + // A bare overflow with no token numbers (Zhipu GLM 1261) is still + // provider-confirmed: trust it with an unknown window so consumers + // fall back to the configured window instead of resending as-is. if isUnnumberedPromptTooLong(message, apiErr.Body) { return &ContextLimitError{APIError: apiErr} } diff --git a/internal/provider/context_limit_test.go b/internal/provider/context_limit_test.go index e937e22f5e..df04262353 100644 --- a/internal/provider/context_limit_test.go +++ b/internal/provider/context_limit_test.go @@ -26,11 +26,9 @@ func TestParseContextLimitErrorNumericJSON(t *testing.T) { } func TestParseContextLimitErrorGLMUnnumbered1261(t *testing.T) { - // Zhipu GLM reports a bare overflow with no token numbers (observed on a - // 2M-token session: glm-cn 400 {"code":"1261","message":"Prompt exceeds max - // length"}). It must be trusted as a context-limit error with an unknown - // window so chunked compaction fallback triggers instead of the oversized - // request failing transparently on every retry. + // Zhipu GLM reports a bare overflow with no token numbers. It must be + // trusted as a context-limit error with an unknown window rather than + // failing the same oversized request on every retry. body := `{"error":{"code":"1261","message":"Prompt exceeds max length"}}` got := ParseContextLimitError(&APIError{Status: 400, Body: body}) if got == nil {