diff --git a/pkg/model/provider/gemini/client.go b/pkg/model/provider/gemini/client.go index ba6cd328b..c1924d9cd 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 9b344ddc0..e68adc7fe 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 a7b39e1f5..866594974 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 62b7a25e5..c52583211 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()