Cycle model thinking level with shift+tab

Pressing Shift+Tab in the TUI now cycles the current model's
thinking-effort level through its provider-specific range (e.g.
off → minimal → low → medium → high → xhigh for OpenAI), wrapping
around. The selected level is applied as a runtime model override
for the active session.

The current level is shown inline in the sidebar's agent block as
`<provider>/<model> • <thinking>`. To avoid a tool-count flicker,
the change refreshes only agent/team info via a new lightweight
Runtime.EmitAgentInfo instead of re-running full startup info (which
re-discovers tools).

Models that don't support reasoning, and remote runtimes, are
no-ops with an informational message.

Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
This commit is contained in:
Djordje Lukic committed 2026-06-11 00:28:22 +02:00
1 parent 2428e6a5bf
commit 36903dd4c6
18 files changed
+629 -47

No files matched your search

+1
View File
@@ -186,6 +186,7 @@ Customize session titles to make them more meaningful and easier to find. By def
| Ctrl+R | Reverse history search (search previous inputs) |
| Ctrl+G | Cancel reverse history search |
| Ctrl+S | Cycle to next agent in the team |
| Shift+Tab | Cycle the current model's thinking-effort level |
| Ctrl+1 – 9 | Switch directly to agent _N_ in the team list |
| Ctrl+T | Open a new tab (additional agent session) |
| Ctrl+W | Close the current tab |
+11
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"math/rand"
"slices"
"sync"
"sync/atomic"
"time"
@@ -252,6 +253,16 @@ func (a *Agent) ConfiguredModels() []provider.Provider {
return a.models
}
// EffectiveModels returns the providers currently in effect for this agent:
// the runtime override(s) when set, otherwise the configured models. The
// returned slice is a copy and safe for the caller to retain or mutate.
func (a *Agent) EffectiveModels() []provider.Provider {
if overrides := a.modelOverrides.Load(); overrides != nil && len(*overrides) > 0 {
return slices.Clone(*overrides)
}
return slices.Clone(a.models)
}
// FallbackModels returns the fallback models to try if the primary model fails.
func (a *Agent) FallbackModels() []provider.Provider {
return a.fallbackModels
+40 -4
View File
@@ -21,6 +21,7 @@ import (
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/hooks/builtins"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
@@ -765,13 +766,22 @@ func (a *App) NewSession() {
// through the events channel so the sidebar updates.
func (a *App) reEmitStartupInfo(ctx context.Context) {
a.runtime.ResetStartupInfo()
a.pumpToEvents(ctx, func(sink runtime.EventSink) {
a.runtime.EmitStartupInfo(ctx, a.session, sink)
})
}
// pumpToEvents runs emit (which produces events into the supplied sink) in a
// background goroutine and forwards everything it emits to the app's events
// channel, without blocking the caller.
func (a *App) pumpToEvents(ctx context.Context, emit func(runtime.EventSink)) {
go func() {
startupEvents := make(chan runtime.Event, 10)
ch := make(chan runtime.Event, 10)
go func() {
defer close(startupEvents)
a.runtime.EmitStartupInfo(ctx, a.session, runtime.NewChannelSink(startupEvents))
defer close(ch)
emit(runtime.NewChannelSink(ch))
}()
for event := range startupEvents {
for event := range ch {
select {
case a.events <- event:
case <-ctx.Done():
@@ -883,6 +893,32 @@ func (a *App) SetCurrentAgentModel(ctx context.Context, modelRef string) error {
return nil
}
// CycleAgentThinkingLevel advances the current agent's thinking-effort level
// to the next value in its provider's cycle and returns the newly selected
// level. The change applies to the running session only (it is not persisted
// as a model override). Returns an error wrapping [runtime.ErrUnsupported]
// when the runtime or model cannot cycle thinking levels.
func (a *App) CycleAgentThinkingLevel(ctx context.Context) (effort.Level, error) {
agentName := a.runtime.CurrentAgentName()
level, err := a.runtime.CycleAgentThinkingLevel(ctx, agentName)
if err != nil {
return "", err
}
// Refresh only the agent/team info so the sidebar reflects the updated
// thinking level. We intentionally avoid reEmitStartupInfo here: it would
// re-run tool discovery and make the sidebar's tool count flicker.
a.refreshAgentInfo(ctx)
return level, nil
}
// refreshAgentInfo pushes fresh agent and team info through the events channel
// without re-running the heavier startup steps (tool discovery, token usage).
func (a *App) refreshAgentInfo(ctx context.Context) {
a.pumpToEvents(ctx, func(sink runtime.EventSink) {
a.runtime.EmitAgentInfo(ctx, sink)
})
}
// AvailableModels returns the list of models available for selection.
// Returns nil if model switching is not supported.
func (a *App) AvailableModels(ctx context.Context) []runtime.ModelChoice {
+7 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/hooks"
"github.com/docker/docker-agent/pkg/hooks/builtins"
"github.com/docker/docker-agent/pkg/runtime"
@@ -42,7 +43,8 @@ func (m *mockRuntime) RestartToolset(context.Context, string) error { retu
func (m *mockRuntime) EmitStartupInfo(ctx context.Context, sess *session.Session, events runtime.EventSink) {
}
func (m *mockRuntime) ResetStartupInfo() {}
func (m *mockRuntime) EmitAgentInfo(context.Context, runtime.EventSink) {}
func (m *mockRuntime) ResetStartupInfo() {}
func (m *mockRuntime) RunStream(ctx context.Context, sess *session.Session) <-chan runtime.Event {
ch := make(chan runtime.Event)
close(ch)
@@ -90,6 +92,10 @@ func (m *mockRuntime) TogglePause(context.Context) (bool, error) { return false,
func (m *mockRuntime) SetAgentModel(context.Context, string, string) error {
return nil
}
func (m *mockRuntime) CycleAgentThinkingLevel(context.Context, string) (effort.Level, error) {
return "", runtime.ErrUnsupported
}
func (m *mockRuntime) AvailableModels(context.Context) []runtime.ModelChoice { return nil }
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
func (m *mockRuntime) OnToolsChanged(func(runtime.Event)) {}
+13 -8
View File
@@ -10,6 +10,7 @@ import (
"gotest.tools/v3/assert"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/sessiontitle"
@@ -60,6 +61,7 @@ func (m *mockRuntime) CurrentAgentTools(context.Context) ([]tools.Tool, error)
func (m *mockRuntime) CurrentAgentToolsetStatuses() []tools.ToolsetStatus { return nil }
func (m *mockRuntime) RestartToolset(context.Context, string) error { return nil }
func (m *mockRuntime) EmitStartupInfo(context.Context, *session.Session, runtime.EventSink) {}
func (m *mockRuntime) EmitAgentInfo(context.Context, runtime.EventSink) {}
func (m *mockRuntime) ResetStartupInfo() {}
func (m *mockRuntime) Run(context.Context, *session.Session) ([]session.Message, error) {
return nil, nil
@@ -87,14 +89,17 @@ func (m *mockRuntime) CurrentMCPPrompts(context.Context) map[string]mcptools.Pro
func (m *mockRuntime) ExecuteMCPPrompt(context.Context, string, map[string]string) (string, error) {
return "", nil
}
func (m *mockRuntime) UpdateSessionTitle(context.Context, *session.Session, string) error { return nil }
func (m *mockRuntime) TitleGenerator() *sessiontitle.Generator { return nil }
func (m *mockRuntime) Close() error { return nil }
func (m *mockRuntime) Steer(runtime.QueuedMessage) error { return nil }
func (m *mockRuntime) FollowUp(runtime.QueuedMessage) error { return nil }
func (m *mockRuntime) QueueStatus() runtime.QueueStatus { return runtime.QueueStatus{} }
func (m *mockRuntime) TogglePause(context.Context) (bool, error) { return false, nil }
func (m *mockRuntime) SetAgentModel(context.Context, string, string) error { return nil }
func (m *mockRuntime) UpdateSessionTitle(context.Context, *session.Session, string) error { return nil }
func (m *mockRuntime) TitleGenerator() *sessiontitle.Generator { return nil }
func (m *mockRuntime) Close() error { return nil }
func (m *mockRuntime) Steer(runtime.QueuedMessage) error { return nil }
func (m *mockRuntime) FollowUp(runtime.QueuedMessage) error { return nil }
func (m *mockRuntime) QueueStatus() runtime.QueueStatus { return runtime.QueueStatus{} }
func (m *mockRuntime) TogglePause(context.Context) (bool, error) { return false, nil }
func (m *mockRuntime) SetAgentModel(context.Context, string, string) error { return nil }
func (m *mockRuntime) CycleAgentThinkingLevel(context.Context, string) (effort.Level, error) {
return "", runtime.ErrUnsupported
}
func (m *mockRuntime) AvailableModels(context.Context) []runtime.ModelChoice { return nil }
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
func (m *mockRuntime) OnToolsChanged(func(runtime.Event)) {}
+60
View File
@@ -74,6 +74,66 @@ func ValidNames() string {
return "none, minimal, low, medium, high, xhigh, max, adaptive, adaptive/<effort>"
}
// ---------------------------------------------------------------------------
// Thinking-level cycling (TUI shift+tab)
// ---------------------------------------------------------------------------
// thinkingCycles maps a normalised provider bucket to the ordered list of
// thinking-effort levels the TUI cycles through with shift+tab. Each cycle
// starts with None (thinking off) and otherwise spans the full range of
// effort levels the provider's API accepts (see the per-provider mapping
// helpers below). Not every model supports the highest tiers (xhigh, max);
// cycling onto an unsupported tier is recoverable by cycling again.
var thinkingCycles = map[string][]Level{
"openai": {None, Minimal, Low, Medium, High, XHigh},
"anthropic": {None, Low, Medium, High, XHigh, Max},
"google": {None, Minimal, Low, Medium, High},
}
// defaultThinkingCycle is used for providers without a dedicated cycle.
var defaultThinkingCycle = []Level{None, Low, Medium, High}
// ThinkingCycle returns the ordered list of selectable thinking-effort
// levels for the given provider type. The provider type is matched
// case-insensitively and tolerant of aliases (e.g. "amazon-bedrock" maps
// onto the underlying Anthropic family).
func ThinkingCycle(providerType string) []Level {
if c, ok := thinkingCycles[providerBucket(providerType)]; ok {
return c
}
return defaultThinkingCycle
}
// NextThinkingLevel returns the level following current in the provider's
// thinking cycle, wrapping back to the first level. When current is not in
// the cycle the first level is returned.
func NextThinkingLevel(providerType string, current Level) Level {
cycle := ThinkingCycle(providerType)
for i, l := range cycle {
if l == current {
return cycle[(i+1)%len(cycle)]
}
}
return cycle[0]
}
// providerBucket normalises a provider type to one of the buckets used by
// thinkingCycles. Returns the lowercased provider type unchanged when no
// alias matches (callers fall back to the default cycle).
func providerBucket(providerType string) string {
p := strings.ToLower(strings.TrimSpace(providerType))
switch {
case strings.Contains(p, "anthropic"), strings.Contains(p, "claude"), strings.Contains(p, "bedrock"):
return "anthropic"
case strings.Contains(p, "google"), strings.Contains(p, "gemini"), strings.Contains(p, "vertex"):
return "google"
case strings.Contains(p, "openai"), strings.Contains(p, "azure"):
return "openai"
default:
return p
}
}
// ---------------------------------------------------------------------------
// Provider-specific mappings
// ---------------------------------------------------------------------------
+76
View File
@@ -185,6 +185,82 @@ func TestIsValidAdaptive(t *testing.T) {
}
}
func TestThinkingCycle(t *testing.T) {
t.Parallel()
tests := []struct {
provider string
want []Level
}{
{"openai", []Level{None, Minimal, Low, Medium, High, XHigh}},
{"openai_responses", []Level{None, Minimal, Low, Medium, High, XHigh}},
{"azure", []Level{None, Minimal, Low, Medium, High, XHigh}},
{"anthropic", []Level{None, Low, Medium, High, XHigh, Max}},
{"amazon-bedrock", []Level{None, Low, Medium, High, XHigh, Max}},
{"google", []Level{None, Minimal, Low, Medium, High}},
{"gemini", []Level{None, Minimal, Low, Medium, High}},
{"vertexai", []Level{None, Minimal, Low, Medium, High}},
{"dmr", []Level{None, Low, Medium, High}},
{"unknown", []Level{None, Low, Medium, High}},
{" OpenAI ", []Level{None, Minimal, Low, Medium, High, XHigh}},
}
for _, tt := range tests {
t.Run(tt.provider, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, ThinkingCycle(tt.provider))
})
}
}
func TestNextThinkingLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
provider string
current Level
want Level
}{
{"openai none to minimal", "openai", None, Minimal},
{"openai high to xhigh", "openai", High, XHigh},
{"openai xhigh wraps to none", "openai", XHigh, None},
{"openai unknown level resets to first", "openai", Max, None},
{"anthropic none to low", "anthropic", None, Low},
{"anthropic high to xhigh", "anthropic", High, XHigh},
{"anthropic xhigh to max", "anthropic", XHigh, Max},
{"anthropic max wraps to none", "anthropic", Max, None},
{"anthropic minimal not in cycle resets to none", "anthropic", Minimal, None},
{"default medium to high", "dmr", Medium, High},
{"default high wraps to none", "dmr", High, None},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, NextThinkingLevel(tt.provider, tt.current))
})
}
}
// TestNextThinkingLevel_FullCycle walks every provider cycle end-to-end and
// asserts that repeatedly advancing returns to the starting level.
func TestNextThinkingLevel_FullCycle(t *testing.T) {
t.Parallel()
for _, provider := range []string{"openai", "anthropic", "google", "dmr"} {
t.Run(provider, func(t *testing.T) {
t.Parallel()
cycle := ThinkingCycle(provider)
cur := cycle[0]
for range cycle {
cur = NextThinkingLevel(provider, cur)
}
assert.Equal(t, cycle[0], cur, "advancing len(cycle) times returns to start")
})
}
}
func TestString(t *testing.T) {
t.Parallel()
+8 -3
View File
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/sessiontitle"
"github.com/docker/docker-agent/pkg/tools"
@@ -38,6 +39,7 @@ func (m *mockRuntime) SetCurrentAgent(string) error {
return nil
}
func (m *mockRuntime) EmitStartupInfo(context.Context, *session.Session, EventSink) {}
func (m *mockRuntime) EmitAgentInfo(context.Context, EventSink) {}
func (m *mockRuntime) ResetStartupInfo() {}
func (m *mockRuntime) RunStream(context.Context, *session.Session) <-chan Event {
return nil
@@ -80,9 +82,12 @@ func (m *mockRuntime) FollowUp(QueuedMessage) error { ret
func (m *mockRuntime) QueueStatus() QueueStatus { return QueueStatus{} }
func (m *mockRuntime) TogglePause(context.Context) (bool, error) { return false, nil }
func (m *mockRuntime) SetAgentModel(context.Context, string, string) error { return nil }
func (m *mockRuntime) AvailableModels(context.Context) []ModelChoice { return nil }
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
func (m *mockRuntime) OnToolsChanged(func(Event)) {}
func (m *mockRuntime) CycleAgentThinkingLevel(context.Context, string) (effort.Level, error) {
return "", ErrUnsupported
}
func (m *mockRuntime) AvailableModels(context.Context) []ModelChoice { return nil }
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
func (m *mockRuntime) OnToolsChanged(func(Event)) {}
func (m *mockRuntime) RegenerateTitle(context.Context, *session.Session, chan Event) {
}
+9 -5
View File
@@ -554,11 +554,15 @@ func AgentInfo(agentName, model, description, welcomeMessage string) Event {
// AgentDetails contains information about an agent for display in the sidebar
type AgentDetails struct {
Name string `json:"name"`
Description string `json:"description"`
Provider string `json:"provider"`
Model string `json:"model"`
Commands types.Commands `json:"commands,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Provider string `json:"provider"`
Model string `json:"model"`
// Thinking is a short label describing the model's current thinking-effort
// level (e.g. "high", "off"). Empty when the model has no selectable
// thinking configuration.
Thinking string `json:"thinking,omitempty"`
Commands types.Commands `json:"commands,omitempty"`
}
// TeamInfoEvent is sent when team information is available
+1 -1
View File
@@ -244,7 +244,7 @@ func (r *LocalRuntime) runStreamLoop(ctx context.Context, sess *session.Session,
}
// Emit team information
sink.Emit(TeamInfo(r.agentDetailsFromTeam(), a.Name()))
sink.Emit(TeamInfo(r.agentDetailsFromTeam(ctx), a.Name()))
r.emitAgentWarnings(a, sink)
r.configureToolsetHandlers(a, sink)
+85
View File
@@ -10,9 +10,11 @@ import (
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/model/provider"
"github.com/docker/docker-agent/pkg/model/provider/options"
"github.com/docker/docker-agent/pkg/modelinfo"
"github.com/docker/docker-agent/pkg/modelsdev"
)
@@ -176,6 +178,89 @@ func (r *LocalRuntime) SupportsModelSwitching() bool {
return r.modelSwitcherCfg != nil
}
// CycleAgentThinkingLevel implements [Runtime.CycleAgentThinkingLevel] for
// LocalRuntime. It reads the agent's current effective model, advances the
// thinking-effort level by one step in that provider's cycle, re-creates the
// provider(s) with the new level, and installs them as a runtime override.
func (r *LocalRuntime) CycleAgentThinkingLevel(ctx context.Context, agentName string) (effort.Level, error) {
if r.modelSwitcherCfg == nil {
return "", ErrUnsupported
}
a, err := r.team.Agent(agentName)
if err != nil {
return "", fmt.Errorf("agent not found: %w", err)
}
models := a.EffectiveModels()
if len(models) == 0 {
return "", errors.New("agent has no model configured")
}
baseCfg := models[0].BaseConfig().ModelConfig
if !r.modelSupportsThinking(ctx, &baseCfg) {
return "", fmt.Errorf("model %q does not support thinking levels: %w", baseCfg.DisplayOrModel(), ErrUnsupported)
}
next := effort.NextThinkingLevel(baseCfg.Provider, currentThinkingLevel(&baseCfg))
// Re-create each effective provider (alloy models can have several) with
// the new thinking level so the override preserves the existing pool.
newProviders := make([]provider.Provider, 0, len(models))
for _, m := range models {
mc := m.BaseConfig().ModelConfig
cfg := mc.Clone()
cfg.ThinkingBudget = &latest.ThinkingBudget{Effort: string(next)}
prov, err := r.createProviderFromConfig(ctx, cfg)
if err != nil {
return "", fmt.Errorf("failed to apply thinking level: %w", err)
}
newProviders = append(newProviders, prov)
}
a.SetModelOverride(newProviders...)
slog.InfoContext(ctx, "Cycled agent thinking level", "agent", agentName, "level", next)
return next, nil
}
// modelSupportsThinking reports whether cfg names a model that accepts a
// user-selectable thinking-effort level. It first trusts an explicit,
// enabled thinking_budget (covers reasoning models the heuristics below
// don't recognise), then falls back to per-provider model heuristics.
func (r *LocalRuntime) modelSupportsThinking(ctx context.Context, cfg *latest.ModelConfig) bool {
if cfg.ThinkingBudget != nil && !cfg.ThinkingBudget.IsDisabled() {
return true
}
if modelinfo.UsesReasoningEffort(cfg.Model) || modelinfo.UsesThinkingLevel(cfg.Model) {
return true
}
model := strings.ToLower(cfg.Model)
if strings.HasPrefix(model, "gemini-2.5") {
return true
}
// Claude family (Anthropic / Bedrock / Vertex) supports adaptive thinking.
if modelinfo.IsBedrockClaudeID(cfg.Model) || strings.HasPrefix(model, "claude-") {
return true
}
if r.modelsStore != nil {
if m, err := r.modelsStore.GetModel(ctx, modelsdev.NewID(cfg.Provider, cfg.Model)); err == nil && m != nil {
return modelinfo.IsClaudeFamily(m.Family)
}
}
return false
}
// currentThinkingLevel maps a model config's thinking_budget onto an
// [effort.Level]. A nil, disabled, token-based, or adaptive budget is
// reported as [effort.None] so the first cycle step lands on a concrete
// effort level.
func currentThinkingLevel(cfg *latest.ModelConfig) effort.Level {
if l, ok := cfg.ThinkingBudget.EffortLevel(); ok {
return l
}
return effort.None
}
// setAgentModelInternal applies modelRef as the agent's model override and
// returns a snapshot of the value that was just stored. The snapshot is
// captured atomically with the store (it is the pointer returned by
+177
View File
@@ -2,14 +2,21 @@ package runtime
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/model/provider/base"
"github.com/docker/docker-agent/pkg/modelsdev"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/tools"
)
// mockCatalogStore implements ModelStore for testing
@@ -23,6 +30,176 @@ func (m *mockCatalogStore) GetDatabase(_ context.Context) (*modelsdev.Database,
return m.db, nil
}
// configProvider is a provider.Provider whose BaseConfig is fully controlled
// by the test, used to drive thinking-level cycling without a real client.
type configProvider struct {
base.Config
}
func (configProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) {
return nil, errors.New("not implemented")
}
func newConfigProvider(cfg latest.ModelConfig) *configProvider {
return &configProvider{Config: base.Config{ModelConfig: cfg}}
}
func TestCurrentThinkingLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
budget *latest.ThinkingBudget
want effort.Level
}{
{"nil budget", nil, effort.None},
{"disabled none", &latest.ThinkingBudget{Effort: "none"}, effort.None},
{"disabled zero tokens", &latest.ThinkingBudget{Tokens: 0}, effort.None},
{"effort high", &latest.ThinkingBudget{Effort: "high"}, effort.High},
{"effort medium", &latest.ThinkingBudget{Effort: "medium"}, effort.Medium},
{"token budget treated as none", &latest.ThinkingBudget{Tokens: 4096}, effort.None},
{"adaptive treated as none", &latest.ThinkingBudget{Effort: "adaptive"}, effort.None},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &latest.ModelConfig{ThinkingBudget: tt.budget}
assert.Equal(t, tt.want, currentThinkingLevel(cfg))
})
}
}
func TestModelSupportsThinking(t *testing.T) {
t.Parallel()
r := &LocalRuntime{} // nil modelsStore: rely on name heuristics only.
tests := []struct {
name string
cfg latest.ModelConfig
want bool
}{
{"openai reasoning gpt-5", latest.ModelConfig{Provider: "openai", Model: "gpt-5"}, true},
{"openai o3", latest.ModelConfig{Provider: "openai", Model: "o3"}, true},
{"openai non-reasoning gpt-4o", latest.ModelConfig{Provider: "openai", Model: "gpt-4o"}, false},
{"openai gpt-5-chat is non-reasoning", latest.ModelConfig{Provider: "openai", Model: "gpt-5-chat"}, false},
{"anthropic claude", latest.ModelConfig{Provider: "anthropic", Model: "claude-sonnet-4-5"}, true},
{"bedrock claude", latest.ModelConfig{Provider: "amazon-bedrock", Model: "us.anthropic.claude-sonnet-4-5"}, true},
{"gemini 3", latest.ModelConfig{Provider: "google", Model: "gemini-3-pro"}, true},
{"gemini 2.5", latest.ModelConfig{Provider: "google", Model: "gemini-2.5-pro"}, true},
{"gemini 2.0 no thinking", latest.ModelConfig{Provider: "google", Model: "gemini-2.0-flash"}, false},
{"explicit thinking budget overrides heuristic", latest.ModelConfig{Provider: "dmr", Model: "deepseek-r1", ThinkingBudget: &latest.ThinkingBudget{Effort: "medium"}}, true},
{"disabled thinking budget does not count", latest.ModelConfig{Provider: "dmr", Model: "llama3", ThinkingBudget: &latest.ThinkingBudget{Effort: "none"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := tt.cfg
assert.Equal(t, tt.want, r.modelSupportsThinking(t.Context(), &cfg))
})
}
}
func TestCycleAgentThinkingLevel_Errors(t *testing.T) {
t.Parallel()
t.Run("nil modelSwitcherCfg is unsupported", func(t *testing.T) {
t.Parallel()
root := agent.New("root", "test")
r := &LocalRuntime{team: team.New(team.WithAgents(root))}
_, err := r.CycleAgentThinkingLevel(t.Context(), "root")
require.ErrorIs(t, err, ErrUnsupported)
})
t.Run("agent not found", func(t *testing.T) {
t.Parallel()
root := agent.New("root", "test")
r := &LocalRuntime{
team: team.New(team.WithAgents(root)),
modelSwitcherCfg: &ModelSwitcherConfig{},
}
_, err := r.CycleAgentThinkingLevel(t.Context(), "missing")
require.Error(t, err)
assert.Contains(t, err.Error(), "agent not found")
})
t.Run("non-reasoning model is unsupported", func(t *testing.T) {
t.Parallel()
model := newConfigProvider(latest.ModelConfig{Provider: "openai", Model: "gpt-4o"})
root := agent.New("root", "test", agent.WithModel(model))
r := &LocalRuntime{
team: team.New(team.WithAgents(root)),
modelSwitcherCfg: &ModelSwitcherConfig{},
}
_, err := r.CycleAgentThinkingLevel(t.Context(), "root")
require.ErrorIs(t, err, ErrUnsupported)
assert.False(t, root.HasModelOverride(), "no override should be set when cycling is unsupported")
})
}
func TestAgentThinkingLabel(t *testing.T) {
t.Parallel()
r := &LocalRuntime{} // nil modelsStore: name heuristics only.
tests := []struct {
name string
cfg latest.ModelConfig
want string
}{
{"reasoning model, no budget shows off", latest.ModelConfig{Provider: "openai", Model: "gpt-5"}, "off"},
{"reasoning model with effort", latest.ModelConfig{Provider: "openai", Model: "gpt-5", ThinkingBudget: &latest.ThinkingBudget{Effort: "high"}}, "high"},
{"reasoning model disabled shows off", latest.ModelConfig{Provider: "openai", Model: "gpt-5", ThinkingBudget: &latest.ThinkingBudget{Effort: "none"}}, "off"},
{"adaptive budget shows on", latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-7", ThinkingBudget: &latest.ThinkingBudget{Effort: "adaptive"}}, "on"},
{"token budget shows on", latest.ModelConfig{Provider: "anthropic", Model: "claude-sonnet-4-5", ThinkingBudget: &latest.ThinkingBudget{Tokens: 4096}}, "on"},
{"non-reasoning model hides line", latest.ModelConfig{Provider: "openai", Model: "gpt-4o"}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
a := agent.New("root", "test", agent.WithModel(newConfigProvider(tt.cfg)))
assert.Equal(t, tt.want, r.agentThinkingLabel(t.Context(), a))
})
}
}
func TestCycleAgentThinkingLevel_AdvancesAndOverrides(t *testing.T) {
t.Parallel()
model := newConfigProvider(latest.ModelConfig{Provider: "openai", Model: "gpt-5"})
root := agent.New("root", "test", agent.WithModel(model))
r := &LocalRuntime{
team: team.New(team.WithAgents(root)),
modelSwitcherCfg: &ModelSwitcherConfig{
EnvProvider: environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "sk-test"}),
},
}
// gpt-5 has no thinking budget configured → current is None, so the first
// cycle step lands on Minimal (the OpenAI cycle is none→minimal→…).
level, err := r.CycleAgentThinkingLevel(t.Context(), "root")
require.NoError(t, err)
assert.Equal(t, effort.Minimal, level)
require.True(t, root.HasModelOverride(), "cycling must install a runtime override")
override := root.Model(t.Context())
require.NotNil(t, override)
budget := override.BaseConfig().ModelConfig.ThinkingBudget
require.NotNil(t, budget)
assert.Equal(t, "minimal", budget.Effort)
// Next step: minimal → low.
level, err = r.CycleAgentThinkingLevel(t.Context(), "root")
require.NoError(t, err)
assert.Equal(t, effort.Low, level)
}
func TestIsInlineAlloySpec(t *testing.T) {
t.Parallel()
+14
View File
@@ -16,6 +16,7 @@ import (
"github.com/docker/docker-agent/pkg/api"
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/sessiontitle"
"github.com/docker/docker-agent/pkg/team"
@@ -189,6 +190,13 @@ func (r *RemoteRuntime) EmitStartupInfo(ctx context.Context, _ *session.Session,
events.Emit(ToolsetInfo(toolCount, false, agentName))
}
// EmitAgentInfo emits agent and team info without re-fetching tool counts.
func (r *RemoteRuntime) EmitAgentInfo(ctx context.Context, events EventSink) {
agentName, cfg := r.resolvedAgent(ctx)
events.Emit(AgentInfo(agentName, cfg.Model, cfg.Description, cfg.WelcomeMessage))
events.Emit(TeamInfo(r.agentDetailsFromConfig(ctx), agentName))
}
func (r *RemoteRuntime) agentDetailsFromConfig(ctx context.Context) []AgentDetails {
cfg, err := r.client.GetAgent(ctx, r.agentFilename)
if err != nil {
@@ -592,6 +600,12 @@ func (r *RemoteRuntime) SetAgentModel(_ context.Context, _, modelRef string) err
return nil
}
// CycleAgentThinkingLevel is unsupported on remote runtimes; the server owns
// model configuration including thinking-effort selection.
func (r *RemoteRuntime) CycleAgentThinkingLevel(context.Context, string) (effort.Level, error) {
return "", ErrUnsupported
}
// SupportsModelSwitching returns true for remote runtimes (model switching is handled server-side).
func (r *RemoteRuntime) SupportsModelSwitching() bool {
return true
+82 -16
View File
@@ -18,6 +18,7 @@ import (
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/hooks"
"github.com/docker/docker-agent/pkg/hooks/builtins"
"github.com/docker/docker-agent/pkg/httpclient"
@@ -60,6 +61,12 @@ type Runtime interface {
// When sess is non-nil and contains token data, a TokenUsageEvent is also emitted
// so the UI can display context usage percentage on session restore.
EmitStartupInfo(ctx context.Context, sess *session.Session, events EventSink)
// EmitAgentInfo emits up-to-date agent and team info (model, thinking
// level, description) without re-running heavier startup work such as
// tool discovery. Used to refresh the UI after a lightweight change like
// cycling the thinking level, avoiding the tool-count flicker that
// re-emitting full startup info would cause.
EmitAgentInfo(ctx context.Context, events EventSink)
// ResetStartupInfo resets the startup info emission flag, allowing re-emission
ResetStartupInfo()
// RunStream starts the agent's interaction loop and returns a channel of events
@@ -123,6 +130,14 @@ type Runtime interface {
// switching (e.g. remote runtimes, where the server owns the choice).
SetAgentModel(ctx context.Context, agentName, modelRef string) error
// CycleAgentThinkingLevel advances the named agent's thinking-effort
// level to the next value in its provider's cycle (wrapping around),
// applies it as a runtime model override, and returns the newly
// selected level. Returns [ErrUnsupported] for runtimes that can't
// switch models (e.g. remote runtimes) or for models that don't
// support thinking-effort selection.
CycleAgentThinkingLevel(ctx context.Context, agentName string) (effort.Level, error)
// AvailableModels returns the models the user can pick from in the
// /model picker. Returns nil for runtimes that don't expose model
// switching; see SupportsModelSwitching for a cheap pre-check.
@@ -908,18 +923,19 @@ func (r *LocalRuntime) getEffectiveModelID(a *agent.Agent) modelsdev.ID {
// agentDetailsFromTeam converts team agent info to AgentDetails for events.
// It accounts for active fallback cooldowns, returning the effective model
// instead of the configured model when a fallback is in effect.
func (r *LocalRuntime) agentDetailsFromTeam() []AgentDetails {
func (r *LocalRuntime) agentDetailsFromTeam(ctx context.Context) []AgentDetails {
agentsInfo := r.team.AgentsInfo()
details := make([]AgentDetails, len(agentsInfo))
for i, info := range agentsInfo {
providerName := info.Provider
modelName := info.Model
var thinking string
// Check if this agent has an active fallback cooldown
cooldownState := r.fallback.cooldowns.Get(info.Name)
if cooldownState != nil {
// Get the agent to access fallback models
if a, err := r.team.Agent(info.Name); err == nil && a != nil {
// Get the agent to access fallbacks and the effective thinking level.
if a, err := r.team.Agent(info.Name); err == nil && a != nil {
// Check if this agent has an active fallback cooldown
cooldownState := r.fallback.cooldowns.Get(info.Name)
if cooldownState != nil {
fallbacks := a.FallbackModels()
if cooldownState.fallbackIndex >= 0 && cooldownState.fallbackIndex < len(fallbacks) {
fb := fallbacks[cooldownState.fallbackIndex].ID()
@@ -927,6 +943,7 @@ func (r *LocalRuntime) agentDetailsFromTeam() []AgentDetails {
modelName = fb.Model
}
}
thinking = r.agentThinkingLabel(ctx, a)
}
details[i] = AgentDetails{
@@ -934,12 +951,37 @@ func (r *LocalRuntime) agentDetailsFromTeam() []AgentDetails {
Description: info.Description,
Provider: providerName,
Model: modelName,
Thinking: thinking,
Commands: info.Commands,
}
}
return details
}
// agentThinkingLabel returns a short, user-facing label for the effective
// thinking-effort level of the agent's current model: the effort level (e.g.
// "high"), "off" when thinking is disabled on a reasoning-capable model, or
// "" when the model has no selectable thinking configuration to display.
func (r *LocalRuntime) agentThinkingLabel(ctx context.Context, a *agent.Agent) string {
models := a.EffectiveModels()
if len(models) == 0 {
return ""
}
cfg := models[0].BaseConfig().ModelConfig
// Only models that can actually reason get a thinking line.
if !r.modelSupportsThinking(ctx, &cfg) {
return ""
}
budget := cfg.ThinkingBudget
if budget == nil || budget.IsDisabled() {
return "off"
}
if l, ok := budget.EffortLevel(); ok {
return l.String()
}
return "on" // token-based or adaptive budget
}
// SessionStore returns the session store for browsing/loading past sessions.
func (r *LocalRuntime) SessionStore() session.Store {
return r.sessionStore
@@ -1022,6 +1064,37 @@ func (r *LocalRuntime) emitToolsChanged() {
r.onToolsChanged(ToolsetInfo(len(agentTools), false, r.CurrentAgentName()))
}
// emitAgentAndTeamInfo sends the AgentInfo and TeamInfo events that drive the
// sidebar's agent/model/thinking display. It returns false when sending was
// aborted (e.g. the context was cancelled). Shared by EmitStartupInfo and
// EmitAgentInfo so both render identical model labels.
func (r *LocalRuntime) emitAgentAndTeamInfo(ctx context.Context, a *agent.Agent, send func(Event) bool) bool {
modelLabel := r.getEffectiveModelID(a).String()
if a.HasHarness() {
modelLabel = agentModelLabel(a)
}
if !send(AgentInfo(a.Name(), modelLabel, a.Description(), a.WelcomeMessage())) {
return false
}
return send(TeamInfo(r.agentDetailsFromTeam(ctx), r.CurrentAgentName()))
}
// EmitAgentInfo implements [Runtime.EmitAgentInfo]: it refreshes the agent and
// team display without touching toolset discovery.
func (r *LocalRuntime) EmitAgentInfo(ctx context.Context, events EventSink) {
a := r.CurrentAgent()
if a == nil {
return
}
r.emitAgentAndTeamInfo(ctx, a, func(event Event) bool {
if ctx.Err() != nil {
return false
}
events.Emit(event)
return true
})
}
// EmitStartupInfo emits initial agent, team, and toolset information for immediate sidebar display.
// When sess is non-nil and contains token data, a TokenUsageEvent is also emitted so that the
// sidebar can display context usage percentage on session restore.
@@ -1043,17 +1116,10 @@ func (r *LocalRuntime) EmitStartupInfo(ctx context.Context, sess *session.Sessio
return true
}
// Emit agent and team information immediately for fast sidebar display
// Use getEffectiveModelID to account for active fallback cooldowns
// Emit agent and team information immediately for fast sidebar display.
// Use getEffectiveModelID to account for active fallback cooldowns.
modelID := r.getEffectiveModelID(a)
modelLabel := modelID.String()
if a.HasHarness() {
modelLabel = agentModelLabel(a)
}
if !send(AgentInfo(a.Name(), modelLabel, a.Description(), a.WelcomeMessage())) {
return
}
if !send(TeamInfo(r.agentDetailsFromTeam(), r.CurrentAgentName())) {
if !r.emitAgentAndTeamInfo(ctx, a, send) {
return
}
+10 -4
View File
@@ -1266,12 +1266,18 @@ func (m *model) renderAgentEntry(content *strings.Builder, agent runtime.AgentDe
content.WriteString(toolcommon.TruncateText(desc, maxWidth))
}
content.WriteString("\n")
content.WriteString(styles.MutedStyle.Render("├ "))
content.WriteString(toolcommon.TruncateText("Provider: "+agent.Provider, maxWidth))
// Collapse provider, model, and thinking into a single line:
// "<provider>/<model> • <thinking>".
modelLine := agent.Model
if agent.Provider != "" {
modelLine = agent.Provider + "/" + agent.Model
}
if agent.Thinking != "" {
modelLine += " • " + agent.Thinking
}
content.WriteString("\n")
content.WriteString(styles.MutedStyle.Render("└ "))
content.WriteString(toolcommon.TruncateText("Model: "+agent.Model, maxWidth))
content.WriteString(toolcommon.TruncateText(modelLine, maxWidth))
}
// isVisuallyBlank returns true if a rendered line contains no visible content.
+17
View File
@@ -16,6 +16,7 @@ import (
"github.com/docker/docker-agent/pkg/app"
"github.com/docker/docker-agent/pkg/browser"
"github.com/docker/docker-agent/pkg/evaluation"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/shellpath"
"github.com/docker/docker-agent/pkg/tools"
@@ -513,6 +514,22 @@ func (m *appModel) handleOpenModelPicker() (tea.Model, tea.Cmd) {
})
}
// handleCycleThinkingLevel advances the current agent's thinking-effort level
// (shift+tab). On success the new level is reflected in the sidebar via the
// re-emitted agent info; only failures surface a notification.
func (m *appModel) handleCycleThinkingLevel() (tea.Model, tea.Cmd) {
if !m.application.SupportsModelSwitching() {
return m, notification.InfoCmd("Thinking levels can't be changed with remote runtimes")
}
if _, err := m.application.CycleAgentThinkingLevel(context.Background()); err != nil {
if errors.Is(err, runtime.ErrUnsupported) {
return m, notification.InfoCmd("Current model does not support thinking levels")
}
return m, notification.ErrorCmd(fmt.Sprintf("Failed to change thinking level: %v", err))
}
return m, nil
}
func (m *appModel) handleChangeModel(modelRef string) (tea.Model, tea.Cmd) {
if err := m.application.SetCurrentAgentModel(context.Background(), modelRef); err != nil {
return m, notification.ErrorCmd(fmt.Sprintf("Failed to change model: %v", err))
+10 -5
View File
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/app"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/sessiontitle"
@@ -54,7 +55,8 @@ func (queueTestRuntime) CurrentAgentToolsetStatuses() []tools.ToolsetStatus
func (queueTestRuntime) RestartToolset(context.Context, string) error { return nil }
func (queueTestRuntime) EmitStartupInfo(context.Context, *session.Session, runtime.EventSink) {
}
func (queueTestRuntime) ResetStartupInfo() {}
func (queueTestRuntime) EmitAgentInfo(context.Context, runtime.EventSink) {}
func (queueTestRuntime) ResetStartupInfo() {}
func (queueTestRuntime) RunStream(context.Context, *session.Session) <-chan runtime.Event {
ch := make(chan runtime.Event)
close(ch)
@@ -91,10 +93,13 @@ func (queueTestRuntime) ExecuteMCPPrompt(context.Context, string, map[string]str
func (queueTestRuntime) UpdateSessionTitle(context.Context, *session.Session, string) error {
return nil
}
func (queueTestRuntime) TitleGenerator() *sessiontitle.Generator { return nil }
func (queueTestRuntime) Steer(runtime.QueuedMessage) error { return nil }
func (queueTestRuntime) FollowUp(runtime.QueuedMessage) error { return nil }
func (queueTestRuntime) SetAgentModel(context.Context, string, string) error { return nil }
func (queueTestRuntime) TitleGenerator() *sessiontitle.Generator { return nil }
func (queueTestRuntime) Steer(runtime.QueuedMessage) error { return nil }
func (queueTestRuntime) FollowUp(runtime.QueuedMessage) error { return nil }
func (queueTestRuntime) SetAgentModel(context.Context, string, string) error { return nil }
func (queueTestRuntime) CycleAgentThinkingLevel(context.Context, string) (effort.Level, error) {
return "", runtime.ErrUnsupported
}
func (queueTestRuntime) AvailableModels(context.Context) []runtime.ModelChoice { return nil }
func (queueTestRuntime) SupportsModelSwitching() bool { return false }
func (queueTestRuntime) OnToolsChanged(func(runtime.Event)) {}
+8
View File
@@ -1774,6 +1774,10 @@ func (m *appModel) AllBindings() []key.Binding {
key.WithKeys("ctrl+z"),
key.WithHelp("Ctrl+z", "suspend"),
),
key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("Shift+Tab", "cycle thinking level"),
),
)
// leanMode already returned above, so only hideSidebar matters here.
@@ -1977,6 +1981,10 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
return m.forwardChat(msg)
// Shift+Tab cycles the current model's thinking-effort level
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
return m.handleCycleThinkingLevel()
// Focus switching: Tab key toggles between content and editor
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
return m.switchFocus()