mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
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:
18 files changed
+629
-47
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)) {}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)) {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user