Merge pull request #71679 from NousResearch/bb/default-effort

fix(desktop): honor the configured reasoning effort instead of assuming medium
This commit is contained in:
brooklyn! 2026-07-25 20:58:00 -05:00 committed by GitHub
commit a606d24cf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 209 additions and 102 deletions

View file

@ -11,7 +11,7 @@ import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, setModelPickerOpen } from '@/store/session'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
import type { ChatBarState } from './types'
@ -48,6 +48,7 @@ export function ModelPill({
const fastMode = useStore(view.$fast)
const reasoningEffort = useStore(view.$reasoningEffort)
const modelSource = useStore($currentModelSource)
const defaultEffort = useStore($defaultReasoningEffort)
const runtimeId = useStore(view.$runtimeId)
const [open, setOpen] = useState(false)
@ -68,7 +69,9 @@ export function ModelPill({
) : (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
<span className="truncate">
{formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })}
</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}

View file

@ -8,11 +8,13 @@ import {
$currentCwd,
$currentFastMode,
$currentReasoningEffort,
$defaultReasoningEffort,
markComposerSelectionManual,
setCurrentCwd,
setCurrentFastMode,
setCurrentModelSource,
setCurrentReasoningEffort
setCurrentReasoningEffort,
setDefaultReasoningEffort
} from '@/store/session'
import { useHermesConfig } from './use-hermes-config'
@ -44,9 +46,31 @@ describe('useHermesConfig refreshHermesConfig', () => {
setCurrentFastMode(false)
setCurrentModelSource('')
setCurrentReasoningEffort('')
setDefaultReasoningEffort('')
persistString(WORKSPACE_CWD_KEY, null)
})
// Regression: the composer keeps a manual model pick sticky, which skips the
// composer reseed. The profile default must still be published, because the
// model picker resolves "the default effort" from it when applying a model's
// preset — otherwise selecting a model silently downgrades a configured
// `agent.reasoning_effort: high` to Hermes' built-in medium.
it('publishes the profile default effort even when a manual pick blocks the composer reseed', async () => {
setCurrentModelSource('manual')
setCurrentReasoningEffort('low')
mockConfig({ agent: { reasoning_effort: 'high' } })
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
await act(async () => {
await result.current.refreshHermesConfig()
})
expect($defaultReasoningEffort.get()).toBe('high')
// The manual pick itself is still respected.
expect($currentReasoningEffort.get()).toBe('low')
})
it('does not let terminal.cwd replace an inactive selected workspace', async () => {
setCurrentCwd('/Users/example/repo/.worktrees/feature')

View file

@ -11,6 +11,7 @@ import {
setCurrentPersonality,
setCurrentReasoningEffort,
setCurrentServiceTier,
setDefaultReasoningEffort,
setIntroPersonality
} from '@/store/session'
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
@ -83,6 +84,12 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort)
const tier = (config.agent?.service_tier ?? '').trim()
// Publish the profile default regardless of whether the composer is
// reseeded below: picker rows and preset application resolve "the
// default" from here, so a manual model pick must not leave them
// rendering/applying Hermes' built-in medium over the user's config.
setDefaultReasoningEffort(reasoning)
const shouldSeedComposer =
!activeSessionIdRef.current &&
getComposerSelectionGeneration() === selectionGeneration &&

View file

@ -11,6 +11,7 @@ import {
Sun,
Wrench
} from '@/lib/icons'
import { REASONING_EFFORTS } from '@/lib/reasoning-effort'
import type { ThemeMode } from '@/themes/context'
import { defineFieldCopy } from './field-copy'
@ -246,7 +247,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'approvals.mode': ['manual', 'smart', 'off'],
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
// '' = inherit the agent's own effort; the rest is the shared scale.
'delegation.reasoning_effort': ['', ...REASONING_EFFORTS],
// NOTE: memory.provider is intentionally NOT listed here. Its options are
// discovery-driven and served by the backend config schema (merged
// per-request in web_server._schema_with_dynamic_provider_options), so

View file

@ -25,6 +25,7 @@ import type {
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualOnboarding, startManualProviderOAuth } from '@/store/onboarding'
@ -81,10 +82,6 @@ export function ModelSettingsSkeleton() {
)
}
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
@ -94,9 +91,6 @@ const isFastTier = (tier: unknown): boolean =>
.toLowerCase()
)
// Reuse the composer's effort labels.
const effortLabelKey = (v: string) => v as 'high' | 'low' | 'max' | 'medium' | 'minimal' | 'ultra' | 'xhigh'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@ -515,7 +509,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.trim()
.toLowerCase()
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium'
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || DEFAULT_REASONING_EFFORT
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
@ -822,9 +816,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
{REASONING_EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[value]}
</SelectItem>
))}
</SelectContent>

View file

@ -13,23 +13,24 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { normalize } from '@/lib/text'
import {
DEFAULT_REASONING_EFFORT,
isThinkingEnabled,
REASONING_EFFORTS,
resolveReasoningEffort
} from '@/lib/reasoning-effort'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
import { markComposerSelectionManual, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
import {
$defaultReasoningEffort,
markComposerSelectionManual,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned
// by the Thinking toggle, not the radio.
const EFFORT_OPTIONS = [
{ value: 'minimal', labelKey: 'minimal' },
{ value: 'low', labelKey: 'low' },
{ value: 'medium', labelKey: 'medium' },
{ value: 'high', labelKey: 'high' },
{ value: 'xhigh', labelKey: 'xhigh' },
{ value: 'max', labelKey: 'max' },
{ value: 'ultra', labelKey: 'ultra' }
] as const
/** How "fast" is achieved for a given model two different mechanisms:
* - `param`: the Anthropic/OpenAI `speed=fast` request parameter.
@ -111,8 +112,9 @@ export function ModelEditSubmenu({
const activeSessionId = useStore(view.$runtimeId)
const touchesPrimary = view.kind === 'primary'
const effortValue = normalizeEffort(effort)
const thinkingOn = isThinkingEnabled(effort)
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const effortValue = resolveReasoningEffort(effort, defaultEffort)
const thinkingOn = isThinkingEnabled(effort, defaultEffort)
// Editing always records the model's global preset (keyed by provider::model,
// not per-surface — a tile edit re-applies to that model everywhere); the
@ -224,7 +226,7 @@ export function ModelEditSubmenu({
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')}
size="xs"
/>
</DropdownMenuItem>
@ -240,14 +242,14 @@ export function ModelEditSubmenu({
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
{EFFORT_OPTIONS.map(option => (
{REASONING_EFFORTS.map(value => (
<DropdownMenuRadioItem
className={dropdownMenuRow}
key={option.value}
key={value}
onSelect={event => event.preventDefault()}
value={option.value}
value={value}
>
{copy[option.labelKey]}
{copy[value]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -258,19 +260,3 @@ export function ModelEditSubmenu({
</DropdownMenuSubContent>
)
}
function isThinkingEnabled(effort: string): boolean {
// Empty = Hermes default (medium) = on; only an explicit "none" is off.
return normalize(effort || 'medium') !== 'none'
}
function normalizeEffort(effort: string): string {
const value = normalize(effort || 'medium')
// Thinking off → no effort selected in the radio group.
if (value === 'none') {
return ''
}
return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium'
}

View file

@ -20,12 +20,8 @@ import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { ChevronDown, ChevronRight } from '@/lib/icons'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import {
currentPickerSelection,
displayModelName,
modelDisplayParts,
reasoningEffortLabel
} from '@/lib/model-status-label'
import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
@ -39,6 +35,7 @@ import {
setModelVisibilityOpen
} from '@/store/model-visibility'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import { $defaultReasoningEffort } from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
@ -84,6 +81,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
const currentProvider = useStore(view.$provider)
const currentReasoningEffort = useStore(view.$reasoningEffort)
const modelPresets = useStore($modelPresets)
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const visibleModels = useStore($visibleModels)
const collapsedProviders = useStore($collapsedProviders)
@ -181,7 +179,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
await applyModelPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{
@ -300,7 +298,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
const meta = [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null
]
.filter(Boolean)
.join(' ')

View file

@ -1986,8 +1986,7 @@ export const ar = defineLocale({
noModels: 'لا توجد نماذج',
editModels: 'تحرير النماذج',
refreshModels: 'تحديث النماذج',
fast: 'سريع',
medium: 'متوسط'
fast: 'سريع'
},
modelOptions: {
noOptions: 'لا توجد خيارات لهذا النموذج',

View file

@ -2306,8 +2306,7 @@ export const en: Translations = {
noModels: 'No models found',
editModels: 'Edit Models…',
refreshModels: 'Refresh Models',
fast: 'Fast',
medium: 'Med'
fast: 'Fast'
},
modelOptions: {
noOptions: 'No options for this model',

View file

@ -2178,8 +2178,7 @@ export const ja = defineLocale({
noModels: 'モデルが見つかりません',
editModels: 'モデルを編集…',
refreshModels: 'モデルを更新',
fast: '高速',
medium: '中'
fast: '高速'
},
modelOptions: {
noOptions: 'このモデルにはオプションがありません',

View file

@ -1915,7 +1915,6 @@ export interface Translations {
editModels: string
refreshModels: string
fast: string
medium: string
}
modelOptions: {
noOptions: string

View file

@ -2105,8 +2105,7 @@ export const zhHant = defineLocale({
noModels: '找不到模型',
editModels: '編輯模型…',
refreshModels: '重新整理模型',
fast: '快速',
medium: '中'
fast: '快速'
},
modelOptions: {
noOptions: '此模型沒有可用選項',

View file

@ -2483,8 +2483,7 @@ export const zh: Translations = {
noModels: '未找到模型',
editModels: '编辑模型…',
refreshModels: '刷新模型',
fast: '快速',
medium: '中'
fast: '快速'
},
modelOptions: {
noOptions: '此模型没有可用选项',

View file

@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
currentPickerSelection,
displayModelName,
formatModelStatusLabel,
reasoningEffortLabel
} from './model-status-label'
import { currentPickerSelection, displayModelName, formatModelStatusLabel } from './model-status-label'
import { reasoningEffortLabel } from './reasoning-effort'
describe('model-status-label', () => {
it('formats display names consistently', () => {
@ -34,9 +30,16 @@ describe('model-status-label', () => {
)
})
it('always surfaces the effort (default medium) so the level is visible', () => {
it('falls back to the profile default effort, then to medium', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
// No session-level effort → the configured profile default is advertised,
// not Hermes' built-in medium.
expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high' })).toBe('GPT-5.5 · High')
// An explicit session effort still wins over the profile default.
expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high', reasoningEffort: 'low' })).toBe(
'GPT-5.5 · Low'
)
})
it('returns just the placeholder name when there is no model', () => {

View file

@ -1,25 +1,4 @@
import { normalize } from '@/lib/text'
const REASONING_LABELS: Record<string, string> = {
none: 'Off',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
max: 'Max',
ultra: 'Ultra'
}
export function reasoningEffortLabel(effort: string): string {
const key = normalize(effort)
if (!key) {
return ''
}
return REASONING_LABELS[key] ?? effort
}
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
@ -99,10 +78,12 @@ export function displayModelName(model: string): string {
return modelDisplayParts(model).name
}
/** Status bar trigger label — model name plus the live session state (effort/fast). */
/** Status bar trigger label model name plus the live session state (effort/fast).
* `defaultEffort` is the profile's configured level, used when the surface has
* no explicit effort so the label never advertises a default the agent won't use. */
export function formatModelStatusLabel(
model: string,
options?: { fastMode?: boolean; reasoningEffort?: string }
options?: { defaultEffort?: string; fastMode?: boolean; reasoningEffort?: string }
): string {
const name = displayModelName(model)
@ -118,9 +99,9 @@ export function formatModelStatusLabel(
parts.push('Fast')
}
// Always surface the effort (empty = Hermes default of medium) so the
// current reasoning level is visible at a glance, not just when non-default.
parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med')
// Always surface the effort so the current reasoning level is visible at a
// glance, not just when non-default.
parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || DEFAULT_REASONING_EFFORT))
return `${name} · ${parts.join(' ')}`
}

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_REASONING_EFFORT,
isReasoningEffort,
isThinkingEnabled,
REASONING_EFFORT_VALUES,
REASONING_EFFORTS,
reasoningEffortLabel,
resolveReasoningEffort
} from './reasoning-effort'
describe('reasoning-effort', () => {
it('keeps the scale ascending and `none` off it', () => {
expect(REASONING_EFFORTS).not.toContain('none')
expect(REASONING_EFFORT_VALUES[0]).toBe('none')
expect(REASONING_EFFORT_VALUES).toHaveLength(REASONING_EFFORTS.length + 1)
})
it('labels every level it claims to support', () => {
for (const effort of REASONING_EFFORT_VALUES) {
expect(reasoningEffortLabel(effort)).not.toBe('')
}
expect(reasoningEffortLabel('')).toBe('')
// Unknown values pass through rather than silently reading as a real level.
expect(reasoningEffortLabel('bogus')).toBe('bogus')
})
it('recognizes only real scale levels', () => {
expect(isReasoningEffort(DEFAULT_REASONING_EFFORT)).toBe(true)
expect(isReasoningEffort('HIGH')).toBe(true)
expect(isReasoningEffort('none')).toBe(false)
expect(isReasoningEffort('bogus')).toBe(false)
})
it('treats empty as inherit and only `none` as off', () => {
expect(isThinkingEnabled('none')).toBe(false)
expect(isThinkingEnabled('high')).toBe(true)
// Empty inherits the fallback, so an off fallback reads as off.
expect(isThinkingEnabled('', 'none')).toBe(false)
expect(isThinkingEnabled('', 'high')).toBe(true)
})
it('resolves a scale value: inherit, off, or clamp', () => {
expect(resolveReasoningEffort('high')).toBe('high')
// Empty inherits the profile default rather than snapping to medium.
expect(resolveReasoningEffort('', 'ultra')).toBe('ultra')
// Off selects nothing on the scale.
expect(resolveReasoningEffort('none')).toBe('')
expect(resolveReasoningEffort('bogus')).toBe(DEFAULT_REASONING_EFFORT)
})
})

View file

@ -0,0 +1,54 @@
import { normalize } from '@/lib/text'
/** Hermes' reasoning levels, in ascending order mirrors the backend's
* VALID_REASONING_EFFORTS (hermes_constants.py). `none` is not a level: it's
* thinking disabled, owned by the Thinking toggle rather than the scale. */
export const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]
/** The scale plus the off state — the full set a config value may hold. */
export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const
/** Hermes' built-in level when neither the surface nor the profile config
* specifies one (mirrors the backend's own fallback). */
export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium'
/** Compact labels for chrome where space is tight (pill, picker rows). Menus
* and settings use the translated `shell.modelOptions` strings instead. */
const SHORT_LABELS: Record<string, string> = {
none: 'Off',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
max: 'Max',
ultra: 'Ultra'
}
export function reasoningEffortLabel(effort: string): string {
const key = normalize(effort)
return key ? (SHORT_LABELS[key] ?? effort) : ''
}
export const isReasoningEffort = (value: string): value is ReasoningEffort =>
REASONING_EFFORTS.includes(normalize(value) as ReasoningEffort)
/** Thinking is on unless a level explicitly says otherwise; an empty value
* means "inherit", so it resolves through `fallback` first. */
export const isThinkingEnabled = (effort: string, fallback: string = DEFAULT_REASONING_EFFORT): boolean =>
normalize(effort || fallback) !== 'none'
/** The level a scale control should show. Empty inherits `fallback`; `none`
* (thinking off) selects nothing; anything unrecognized clamps to the default. */
export function resolveReasoningEffort(effort: string, fallback: string = DEFAULT_REASONING_EFFORT): string {
const value = normalize(effort || fallback)
if (value === 'none') {
return ''
}
return isReasoningEffort(value) ? value : DEFAULT_REASONING_EFFORT
}

View file

@ -454,6 +454,14 @@ export const setCurrentReasoningEffort = (next: Updater<string>) => {
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
}
// The profile's `agent.reasoning_effort`, mirrored from config so surfaces that
// need to render or apply "the default" resolve the user's configured level
// instead of assuming DEFAULT_REASONING_EFFORT (lib/reasoning-effort). Empty
// until config loads, and re-seeded on every profile switch by useHermesConfig.
export const $defaultReasoningEffort = atom('')
export const setDefaultReasoningEffort = (next: string) => updateAtom($defaultReasoningEffort, next)
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
export const setCurrentFastMode = (next: Updater<boolean>) => {