Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
90 changes: 90 additions & 0 deletions decision_empty_content_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cogito

import (
"context"
"strings"
"testing"

"github.com/sashabaranov/go-openai"
)

// countingStreamLLM is a StreamingLLM whose stream replays a fixed set of events
// on every call and counts how many times a stream was opened. It lets tests
// assert both the returned error and how many decision attempts were made.
type countingStreamLLM struct {
events []StreamEvent
calls int
}

func (m *countingStreamLLM) Ask(ctx context.Context, f Fragment) (Fragment, error) {
return f, nil
}

func (m *countingStreamLLM) CreateChatCompletion(ctx context.Context, request openai.ChatCompletionRequest) (LLMReply, LLMUsage, error) {
return LLMReply{}, LLMUsage{}, nil
}

func (m *countingStreamLLM) CreateChatCompletionStream(ctx context.Context, request openai.ChatCompletionRequest) (<-chan StreamEvent, error) {
m.calls++
ch := make(chan StreamEvent, len(m.events))
for _, ev := range m.events {
ch <- ev
}
close(ch)
return ch, nil
}

// TestDecisionWithStreamingTruncatedEmptyContent reproduces the real-world
// failure behind "%!w(<nil>)": a reasoning model whose reasoning exhausts the
// output-token budget streams reasoning + finish_reason=length but no visible
// content and no tool calls. The decision loop must NOT emit a nil-wrapped error
// and must NOT waste retries on a truncation that will repeat identically.
func TestDecisionWithStreamingTruncatedEmptyContent(t *testing.T) {
llm := &countingStreamLLM{
events: []StreamEvent{
{Type: StreamEventReasoning, Content: "Let me analyze the image in great detail..."},
{Type: StreamEventDone, FinishReason: "length"},
},
}

conv := []openai.ChatCompletionMessage{{Role: "user", Content: "whats in this image"}}
_, err := decisionWithStreaming(context.Background(), llm, conv, Tools{}, "", 3, func(ev StreamEvent) {})

if err == nil {
t.Fatal("expected an error when the decision truncates with empty content, got nil")
}
msg := err.Error()
if strings.Contains(msg, "%!w") || strings.Contains(msg, "<nil>") {
t.Fatalf("error masks the real cause with a nil wrap: %q", msg)
}
if !strings.Contains(msg, "length") {
t.Errorf("error should name the truncation cause (finish_reason=length), got %q", msg)
}
// finish_reason=length is not transient: retrying truncates identically, so
// the loop must fail fast on the first attempt instead of burning retries.
if llm.calls != 1 {
t.Errorf("expected fail-fast (1 attempt) on finish_reason=length, got %d attempts", llm.calls)
}
}

// TestDecisionWithStreamingEmptyContentNoFinishReason covers a genuinely empty
// (non-truncated) response: no content, no tool calls, finish_reason=stop. This
// is retryable, but after exhausting retries the loop must still surface a real
// error, never the "%!w(<nil>)" nil wrap.
func TestDecisionWithStreamingEmptyContentNoFinishReason(t *testing.T) {
llm := &countingStreamLLM{
events: []StreamEvent{
{Type: StreamEventDone, FinishReason: "stop"},
},
}

conv := []openai.ChatCompletionMessage{{Role: "user", Content: "hi"}}
_, err := decisionWithStreaming(context.Background(), llm, conv, Tools{}, "", 2, func(ev StreamEvent) {})

if err == nil {
t.Fatal("expected an error when every attempt produces empty content, got nil")
}
if msg := err.Error(); strings.Contains(msg, "%!w") || strings.Contains(msg, "<nil>") {
t.Fatalf("error masks the real cause with a nil wrap: %q", msg)
}
}
19 changes: 17 additions & 2 deletions tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ func decisionWithStreaming(ctx context.Context, llm LLM, conversation []openai.C
var toolCallOrder []int
var streamErr error
var usage LLMUsage
var finishReason string

for ev := range ch {
streamCB(ev)
Expand Down Expand Up @@ -345,6 +346,7 @@ func decisionWithStreaming(ctx context.Context, llm LLM, conversation []openai.C
tc.Function.Arguments += ev.ToolArgs
case StreamEventDone:
usage = ev.Usage
finishReason = ev.FinishReason
case StreamEventError:
streamErr = ev.Error
}
Expand Down Expand Up @@ -372,8 +374,21 @@ func decisionWithStreaming(ctx context.Context, llm LLM, conversation []openai.C

if len(toolCalls) == 0 {
if content == "" {
// Model produced no visible content (empty response or only reasoning) — retry
xlog.Warn("Streaming decision produced no content, retrying", "attempt", attempts+1)
// The model produced no visible content and selected no tool.
if finishReason == "length" {
// Truncated before any content: the output-token budget was
// exhausted (commonly by a reasoning model's own reasoning,
// especially on image turns where vision tokens crowd the
// context). Retrying truncates identically, so fail fast with an
// actionable error rather than looping — and never wrap a nil
// error into a "%!w(<nil>)" that hides the real cause.
return nil, fmt.Errorf("streaming decision truncated before producing content (finish_reason=length): the model exhausted its output-token budget, likely on reasoning — raise max tokens/context or reduce prompt size (e.g. a large image)")
}
// Genuinely empty response (e.g. finish_reason=stop with no
// content) — retryable, but record why so the final error after
// exhausting retries is a real cause, never a nil wrap.
lastErr = fmt.Errorf("streaming decision produced no content (finish_reason=%q) on attempt %d", finishReason, attempts+1)
xlog.Warn("Streaming decision produced no content, retrying", "attempt", attempts+1, "finishReason", finishReason)
if werr := backoffOrCancel(ctx, attempts); werr != nil {
return nil, werr
}
Expand Down
Loading