diff --git a/internal/agent/compact.go b/internal/agent/compact.go index 37ba5663c5..a94f7d5a5d 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,24 @@ 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 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") + } + return truncateUTF8Bytes(r, summaryReasoningMaxBytes), 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, provider.ChunkToolCallStart: + 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..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" @@ -107,6 +108,56 @@ 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) + } +} + +// 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 26b89a2791..7955c71f8d 100644 --- a/internal/agent/compact_test.go +++ b/internal/agent/compact_test.go @@ -26,11 +26,13 @@ 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 + 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 + hang bool // when true, Stream returns a channel that never sends or closes } func (f *fakeProvider) Name() string { return "fake" } @@ -50,7 +52,17 @@ 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} + 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} + } if f.promptTokens > 0 { ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: f.promptTokens, TotalTokens: f.promptTokens}} } 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 26e44e5e86..6aa011d268 100644 --- a/internal/provider/context_limit.go +++ b/internal/provider/context_limit.go @@ -222,11 +222,20 @@ func ParseContextLimitError(apiErr *APIError) *ContextLimitError { } else if w, r, p, c, ok := parseContextLimitText(message); ok { window, requested, prompt, completion = w, r, p, c } else { + // 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} + } 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 +250,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..df04262353 100644 --- a/internal/provider/context_limit_test.go +++ b/internal/provider/context_limit_test.go @@ -25,6 +25,29 @@ func TestParseContextLimitErrorNumericJSON(t *testing.T) { } } +func TestParseContextLimitErrorGLMUnnumbered1261(t *testing.T) { + // 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 { + 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")