Skip to content
Open
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
8 changes: 6 additions & 2 deletions pkg/model/provider/gemini/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions pkg/model/provider/gemini/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 3 additions & 5 deletions pkg/sessiontitle/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions pkg/sessiontitle/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()

Expand Down
Loading