Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions internal/agent/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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():
Expand All @@ -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]
}
Comment thread
SivanCola marked this conversation as resolved.
Outdated
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++
Comment thread
SivanCola marked this conversation as resolved.
Outdated
case provider.ChunkUsage:
usage = chunk.Usage
case provider.ChunkError:
Expand Down
19 changes: 19 additions & 0 deletions internal/agent/compact_summary_failure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 14 additions & 6 deletions internal/agent/compact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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}}
}
Expand Down
33 changes: 33 additions & 0 deletions internal/provider/context_limit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions internal/provider/context_limit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down