Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
# beans-85nf
title: Configurable default effort level via .beans.yml
status: completed
type: feature
priority: normal
created_at: 2026-03-17T19:14:11Z
updated_at: 2026-03-18T06:53:58Z
---

Add agent.default_effort config option to .beans.yml to set the project-level default thinking effort for new agent sessions

## Context
The agent composer UI hardcodes "high" as the visual default effort level
(`effort === 'high' || !effort` in `AgentComposer.svelte:290`). There is no
`.beans.yml` config option to set a project-level default. Users who prefer a
lower effort level must change it manually each session.

The backend already supports per-session effort via `session.Effort` and the
`setAgentEffort` mutation — it just has no way to seed that value from config.

## Higher Goal
Effort level has cost and latency implications. Projects (or users) should be
able to express a preferred default rather than being silently locked into
"high" for every new session.

## Acceptance Criteria
- [x] `agent.default_effort` field added to `.beans.yml` config (values: `low`, `medium`, `high`, `max`; omitting the field preserves current behavior)
- [x] New agent sessions are initialized with `session.Effort` set to the configured default (so `--effort` is passed to the Claude CLI from the start)
- [x] The UI effort selector reflects the actual session effort rather than hardcoding `high` as the fallback

## Out of Scope
- Per-user (rather than per-project) default effort
- Changing the effort level mid-session behavior (already works)

## Summary of Changes

- Added `agent.default_effort` config field to `AgentConfig` with `GetDefaultEffort()` validation method
- Extracted `newBaseSession()` helper in agent manager that applies both default mode and default effort to all newly created sessions (covering `AddInfoMessage`, `SetPlanMode`, `SetActMode`, `loadOrCreateSession`, and the disk-restore path in `GetSession`)
- Wired `cfg.GetDefaultEffort()` into `serve.go` via `agentMgr.SetDefaultEffort()`
- Fixed `AgentComposer.svelte` to remove the `|| !effort` fallback so the UI accurately reflects actual session effort
2 changes: 1 addition & 1 deletion frontend/src/lib/components/AgentComposer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@
disabled={isRunning}
class={[
'btn-tab-sm cursor-pointer border-l-0',
effort === 'high' || !effort
effort === 'high'
? 'border-accent/30 bg-accent/10 text-accent'
: 'btn-tab-sm-inactive'
]}
Expand Down
82 changes: 42 additions & 40 deletions internal/agent/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,20 @@ type OnTurnCompleteFunc func(beanID string)
type DefaultMode string

const (
DefaultModeAct DefaultMode = "act"
DefaultModeAct DefaultMode = "act"
DefaultModePlan DefaultMode = "plan"
)

// EffortLevel controls the thinking effort for new agent sessions.
type EffortLevel string

const (
EffortLevelLow EffortLevel = "low"
EffortLevelMedium EffortLevel = "medium"
EffortLevelHigh EffortLevel = "high"
EffortLevelMax EffortLevel = "max"
)

// Manager manages agent sessions — one per worktree (keyed by beanID).
// It holds sessions in memory and provides pub/sub for session updates.
type Manager struct {
Expand All @@ -42,7 +52,8 @@ type Manager struct {
systemPromptProvider SystemPromptProvider
onFirstUserMessage OnFirstUserMessageFunc
onTurnComplete OnTurnCompleteFunc
defaultMode DefaultMode
defaultMode DefaultMode
defaultEffort EffortLevel

subMu sync.Mutex
subscribers map[string][]chan struct{}
Expand Down Expand Up @@ -122,15 +133,9 @@ func (m *Manager) GetSession(beanID string) *Session {
m.mu.Unlock()
return &snap
}
s = &Session{
ID: beanID,
AgentType: "claude",
Status: StatusIdle,
Messages: msgs,
SessionID: sessionID,
streamingIdx: -1,
}
m.applyDefaultMode(s)
s = m.newBaseSession(beanID)
s.Messages = msgs
s.SessionID = sessionID
m.sessions[beanID] = s
m.mu.Unlock()
}
Expand Down Expand Up @@ -327,13 +332,7 @@ func (m *Manager) AddInfoMessage(beanID, content string) {
m.mu.Lock()
session, ok := m.sessions[beanID]
if !ok {
session = &Session{
ID: beanID,
AgentType: "claude",
Status: StatusIdle,
streamingIdx: -1,
}
m.applyDefaultMode(session)
session = m.newBaseSession(beanID)
m.sessions[beanID] = session
}
session.Messages = append(session.Messages, msg)
Expand All @@ -356,13 +355,8 @@ func (m *Manager) SetPlanMode(beanID string, planMode bool) error {
session, hasSession := m.sessions[beanID]
if !hasSession {
// Create session in memory so the mode is set before any messages
session = &Session{
ID: beanID,
AgentType: "claude",
Status: StatusIdle,
PlanMode: planMode,
streamingIdx: -1,
}
session = m.newBaseSession(beanID)
session.PlanMode = planMode
m.sessions[beanID] = session
m.mu.Unlock()
m.notify(beanID)
Expand Down Expand Up @@ -397,13 +391,8 @@ func (m *Manager) SetActMode(beanID string, actMode bool) error {
m.mu.Lock()
session, hasSession := m.sessions[beanID]
if !hasSession {
session = &Session{
ID: beanID,
AgentType: "claude",
Status: StatusIdle,
ActMode: actMode,
streamingIdx: -1,
}
session = m.newBaseSession(beanID)
session.ActMode = actMode
m.sessions[beanID] = session
m.mu.Unlock()
m.notify(beanID)
Expand Down Expand Up @@ -580,6 +569,25 @@ func (m *Manager) Shutdown() {
wg.Wait()
}

// SetDefaultEffort sets the default effort level applied to newly created sessions.
// Must be called during initialization, before any sessions are created.
func (m *Manager) SetDefaultEffort(effort EffortLevel) {
m.defaultEffort = effort
}

// newBaseSession returns a freshly initialized session with default mode and effort applied.
func (m *Manager) newBaseSession(beanID string) *Session {
s := &Session{
ID: beanID,
AgentType: "claude",
Status: StatusIdle,
Effort: string(m.defaultEffort),
streamingIdx: -1,
}
m.applyDefaultMode(s)
return s
}

// applyDefaultMode sets ActMode and PlanMode on a session based on the manager's default.
func (m *Manager) applyDefaultMode(s *Session) {
switch m.defaultMode {
Expand All @@ -595,14 +603,8 @@ func (m *Manager) applyDefaultMode(s *Session) {
// loadOrCreateSession loads a session from disk if persisted, or creates a new one.
// Must be called with m.mu held.
func (m *Manager) loadOrCreateSession(beanID, workDir string) *Session {
session := &Session{
ID: beanID,
AgentType: "claude",
Status: StatusIdle,
WorkDir: workDir,
streamingIdx: -1,
}
m.applyDefaultMode(session)
session := m.newBaseSession(beanID)
session.WorkDir = workDir

if m.systemPromptProvider != nil {
session.SystemPrompt = m.systemPromptProvider(beanID)
Expand Down
32 changes: 32 additions & 0 deletions internal/agent/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -968,3 +968,35 @@ func TestShutdown(t *testing.T) {
// Just verify it doesn't panic with no processes
m.Shutdown()
}

func TestSetDefaultEffort_AppliedToNewSession(t *testing.T) {
m := NewManager("", nil)
m.SetDefaultEffort(EffortLevelMedium)

// loadOrCreateSession is called by SendMessage internally; invoke it directly
// via the exported path by checking the session after AddInfoMessage creates one.
m.AddInfoMessage("test-bean", "hello")

snap := m.GetSession("test-bean")
if snap == nil {
t.Fatal("expected session to exist")
}
if snap.Effort != "medium" {
t.Errorf("expected default effort 'medium', got %q", snap.Effort)
}
}

func TestSetDefaultEffort_EmptyMeansNoEffort(t *testing.T) {
m := NewManager("", nil)
// default is empty — no effort applied

m.AddInfoMessage("test-bean", "hello")

snap := m.GetSession("test-bean")
if snap == nil {
t.Fatal("expected session to exist")
}
if snap.Effort != "" {
t.Errorf("expected empty effort, got %q", snap.Effort)
}
}
9 changes: 9 additions & 0 deletions internal/commands/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ Note: Cycles cannot be auto-fixed and require manual intervention.`,
}
}

// 2c. Check agent.default_effort is a valid effort level
if effort := cfg.GetDefaultEffort(); effort != "" && !config.IsValidEffortLevel(effort) {
configErrors = append(configErrors, fmt.Sprintf("agent.default_effort '%s' is not valid (use low, medium, high, or max)", effort))
} else if effort != "" {
if !checkJSON {
fmt.Printf(" %s Default effort '%s' is valid\n", ui.Success.Render("✓"), effort)
}
}

// 3. Check all status colors are valid (hardcoded statuses)
for _, s := range config.DefaultStatuses {
if !ui.IsValidColor(s.Color) {
Expand Down
3 changes: 3 additions & 0 deletions internal/commands/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ func runServer(port int, origins []string) error {
}
return sb.String()
}, agent.DefaultMode(cfg.GetDefaultMode()))
if effort := cfg.GetDefaultEffort(); config.IsValidEffortLevel(effort) {
agentMgr.SetDefaultEffort(agent.EffortLevel(effort))
}
defer agentMgr.Shutdown()

// Inject a system prompt that tells the agent which worktree/directory it's in.
Expand Down
21 changes: 21 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ type AgentConfig struct {
// Valid values: "act" (fully autonomous), "plan" (read-only).
// Default: "act"
DefaultMode PermissionMode `yaml:"default_mode,omitempty"`

// DefaultEffort is the default thinking effort level for new agent sessions.
// Valid values: "low", "medium", "high", "max".
// When omitted, new sessions start with no effort override (uses CLI default).
DefaultEffort string `yaml:"default_effort,omitempty"`
}

// ProjectConfig defines project-level settings.
Expand Down Expand Up @@ -751,6 +756,22 @@ func (c *Config) GetDefaultMode() PermissionMode {
}
}

// GetDefaultEffort returns the raw configured default effort level for agent sessions.
// Returns empty string if not set. Use IsValidEffortLevel to validate before use.
func (c *Config) GetDefaultEffort() string {
return c.Agent.DefaultEffort
}

// IsValidEffortLevel returns true if the effort level is a valid value.
func IsValidEffortLevel(effort string) bool {
switch effort {
case "low", "medium", "high", "max":
return true
default:
return false
}
}

// IsValidPermissionMode returns true if the mode is a valid permission mode.
func IsValidPermissionMode(mode string) bool {
switch PermissionMode(mode) {
Expand Down
48 changes: 48 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,54 @@ func TestIsValidPermissionMode(t *testing.T) {
}
}

func TestGetDefaultEffort(t *testing.T) {
tests := []struct {
effort string
expected string
}{
{"", ""},
{"low", "low"},
{"medium", "medium"},
{"high", "high"},
{"max", "max"},
{"ultra", "ultra"}, // raw value returned; validation is caller's responsibility
}

for _, tt := range tests {
t.Run(tt.effort, func(t *testing.T) {
cfg := Default()
cfg.Agent.DefaultEffort = tt.effort
got := cfg.GetDefaultEffort()
if got != tt.expected {
t.Errorf("GetDefaultEffort() = %q, want %q", got, tt.expected)
}
})
}
}

func TestIsValidEffortLevel(t *testing.T) {
tests := []struct {
effort string
want bool
}{
{"low", true},
{"medium", true},
{"high", true},
{"max", true},
{"", false},
{"ultra", false},
{"High", false},
}

for _, tt := range tests {
t.Run(tt.effort, func(t *testing.T) {
if got := IsValidEffortLevel(tt.effort); got != tt.want {
t.Errorf("IsValidEffortLevel(%q) = %v, want %v", tt.effort, got, tt.want)
}
})
}
}

func TestLoadAgentPermissionMode(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ConfigFileName)
Expand Down
Loading