From 3f73498b43aba9a29a9ba50e43bb87da41421d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Fri, 21 Aug 2026 00:12:08 +0200 Subject: [PATCH] fix(#3996): leave reasoning headroom for Gemini session titles Omit thinkingConfig for title-generation requests so image-capable Gemini models can return plain text titles. Increase the shared title output budget from 20 to 128 tokens to accommodate hidden reasoning while keeping the visible title separately capped at 50 characters. Preserve explicit no-thinking behavior for MCP sampling and ordinary requests, including Gemini 3 minimum reasoning settings and Gemini 2.5 zero-budget suppression. Keep user-configured thinking budgets outside title generation unchanged and test both paths. --- pkg/model/provider/gemini/client.go | 8 ++- pkg/model/provider/gemini/client_test.go | 64 ++++++++++++++++++++++++ pkg/sessiontitle/generator.go | 8 ++- pkg/sessiontitle/generator_test.go | 23 +++++++++ 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/pkg/model/provider/gemini/client.go b/pkg/model/provider/gemini/client.go index ba6cd328bb..c1924d9cd8 100644 --- a/pkg/model/provider/gemini/client.go +++ b/pkg/model/provider/gemini/client.go @@ -425,7 +425,7 @@ func extractMimeType(dataURLPrefix string) string { return "image/jpeg" // Default fallback } -// buildConfig creates GenerateContentConfig from model config +// BuildConfig creates GenerateContentConfig from model config. func (c *Client) buildConfig() *genai.GenerateContentConfig { config := &genai.GenerateContentConfig{} if c.ModelConfig.MaxTokens != nil { @@ -453,7 +453,11 @@ func (c *Client) buildConfig() *genai.GenerateContentConfig { // Apply thinking configuration for Gemini models. // See https://ai.google.dev/gemini-api/docs/thinking if c.ModelOptions.NoThinking() { - // NoThinking requested (e.g. title generation). For Gemini 3+ models + if c.ModelOptions.GeneratingTitle() { + return config + } + + // NoThinking requested (e.g. MCP sampling). For Gemini 3+ models // that always think, use the lowest level and bump MaxOutputTokens so // internal reasoning doesn't consume the entire budget. Gemini 2.5 and // older can fully disable thinking with ThinkingBudget=0. diff --git a/pkg/model/provider/gemini/client_test.go b/pkg/model/provider/gemini/client_test.go index 9b344ddc0d..e68adc7fe7 100644 --- a/pkg/model/provider/gemini/client_test.go +++ b/pkg/model/provider/gemini/client_test.go @@ -10,10 +10,74 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/modelsdev" "github.com/docker/docker-agent/pkg/tools" ) +func TestBuildConfig_NoThinking(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + opts []options.Opt + wantThinking bool + wantMinTokens bool + }{ + { + name: "title generation omits thinking config", + model: "gemini-3-flash", + opts: []options.Opt{options.WithGeneratingTitle(), options.WithNoThinking()}, + wantThinking: false, + }, + { + name: "MCP sampling disables Gemini 3 thinking", + model: "gemini-3-flash", + opts: []options.Opt{options.WithNoThinking()}, + wantThinking: true, + wantMinTokens: true, + }, + { + name: "MCP sampling disables Gemini 2.5 thinking", + model: "gemini-2.5-flash", + opts: []options.Opt{options.WithNoThinking()}, + wantThinking: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := &Client{Config: base.Config{ + ModelConfig: latest.ModelConfig{ + Provider: "google", + Model: tt.model, + ThinkingBudget: &latest.ThinkingBudget{Effort: "high"}, + }, + ModelOptions: options.Apply(tt.opts...), + }} + + config := client.buildConfig() + if !tt.wantThinking { + assert.Nil(t, config.ThinkingConfig) + return + } + + require.NotNil(t, config.ThinkingConfig) + assert.False(t, config.ThinkingConfig.IncludeThoughts) + if tt.wantMinTokens { + assert.Equal(t, genai.ThinkingLevelLow, config.ThinkingConfig.ThinkingLevel) + assert.GreaterOrEqual(t, config.MaxOutputTokens, int32(200)) + return + } + require.NotNil(t, config.ThinkingConfig.ThinkingBudget) + assert.Zero(t, *config.ThinkingConfig.ThinkingBudget) + }) + } +} + func TestBuildConfig_Gemini25_ThinkingBudget(t *testing.T) { t.Parallel() diff --git a/pkg/sessiontitle/generator.go b/pkg/sessiontitle/generator.go index a7b39e1f50..8665949748 100644 --- a/pkg/sessiontitle/generator.go +++ b/pkg/sessiontitle/generator.go @@ -28,11 +28,9 @@ const ( systemPrompt = "You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given up to 2 recent user messages and asked to create a single-line title that captures the main topic. Never use newlines or line breaks in your response." userPromptFormat = "Based on the following recent user messages from a conversation with an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text on a single line, nothing else. Do not include any newlines, explanations, or formatting.\n\nRecent user messages:\n%s\n\n" - // titleMaxTokens is the max output token budget for title generation. - // This is sized for visible output only (~50 chars ≈ 12-15 tokens). - // Providers that need extra headroom for hidden reasoning tokens - // (e.g. OpenAI reasoning models) handle the adjustment internally. - titleMaxTokens = 20 + // Gemini 3 may still consume a small hidden reasoning budget even when + // thinking is disabled. The visible title is capped separately at 50 chars. + titleMaxTokens = 128 // titleGenerationTimeout is the maximum time to wait for title generation. // Title generation should be quick since we disable thinking and use low max_tokens. diff --git a/pkg/sessiontitle/generator_test.go b/pkg/sessiontitle/generator_test.go index 62b7a25e5e..c525832117 100644 --- a/pkg/sessiontitle/generator_test.go +++ b/pkg/sessiontitle/generator_test.go @@ -12,6 +12,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/modelsdev" "github.com/docker/docker-agent/pkg/tools" ) @@ -76,6 +77,28 @@ func streamWithContent(content string) chat.MessageStream { } } +func TestGenerateOnceUsesTitleHeadroomAndClearsStructuredOutput(t *testing.T) { + t.Parallel() + + structured := &latest.StructuredOutput{Schema: map[string]any{"type": "object"}} + baseProvider := &mockProvider{ + id: modelsdev.NewID("google", "gemini-3-flash"), + baseCfgFn: func() base.Config { + maxTokens := int64(7) + return base.Config{ + ModelConfig: latest.ModelConfig{MaxTokens: &maxTokens}, + ModelOptions: options.Apply(options.WithStructuredOutput(structured)), + } + }, + createFn: func() (chat.MessageStream, error) { return streamWithContent("Title"), nil }, + } + + _, err := generateOnce(t.Context(), baseProvider, buildPrompt([]string{"hello"})) + require.NoError(t, err) + assert.Equal(t, 1, baseProvider.calls, "a failed clone would call the base provider and lose title-specific options") + assert.Equal(t, 128, titleMaxTokens) +} + func TestGenerator_Generate_FallsBackOnStreamCreateError(t *testing.T) { t.Parallel()