mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
commit
a606d24cf2
18 changed files with 209 additions and 102 deletions
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(' ')
|
||||
|
|
|
|||
|
|
@ -1986,8 +1986,7 @@ export const ar = defineLocale({
|
|||
noModels: 'لا توجد نماذج',
|
||||
editModels: 'تحرير النماذج',
|
||||
refreshModels: 'تحديث النماذج',
|
||||
fast: 'سريع',
|
||||
medium: 'متوسط'
|
||||
fast: 'سريع'
|
||||
},
|
||||
modelOptions: {
|
||||
noOptions: 'لا توجد خيارات لهذا النموذج',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2178,8 +2178,7 @@ export const ja = defineLocale({
|
|||
noModels: 'モデルが見つかりません',
|
||||
editModels: 'モデルを編集…',
|
||||
refreshModels: 'モデルを更新',
|
||||
fast: '高速',
|
||||
medium: '中'
|
||||
fast: '高速'
|
||||
},
|
||||
modelOptions: {
|
||||
noOptions: 'このモデルにはオプションがありません',
|
||||
|
|
|
|||
|
|
@ -1915,7 +1915,6 @@ export interface Translations {
|
|||
editModels: string
|
||||
refreshModels: string
|
||||
fast: string
|
||||
medium: string
|
||||
}
|
||||
modelOptions: {
|
||||
noOptions: string
|
||||
|
|
|
|||
|
|
@ -2105,8 +2105,7 @@ export const zhHant = defineLocale({
|
|||
noModels: '找不到模型',
|
||||
editModels: '編輯模型…',
|
||||
refreshModels: '重新整理模型',
|
||||
fast: '快速',
|
||||
medium: '中'
|
||||
fast: '快速'
|
||||
},
|
||||
modelOptions: {
|
||||
noOptions: '此模型沒有可用選項',
|
||||
|
|
|
|||
|
|
@ -2483,8 +2483,7 @@ export const zh: Translations = {
|
|||
noModels: '未找到模型',
|
||||
editModels: '编辑模型…',
|
||||
refreshModels: '刷新模型',
|
||||
fast: '快速',
|
||||
medium: '中'
|
||||
fast: '快速'
|
||||
},
|
||||
modelOptions: {
|
||||
noOptions: '此模型没有可用选项',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(' ')}`
|
||||
}
|
||||
|
|
|
|||
53
apps/desktop/src/lib/reasoning-effort.test.ts
Normal file
53
apps/desktop/src/lib/reasoning-effort.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
54
apps/desktop/src/lib/reasoning-effort.ts
Normal file
54
apps/desktop/src/lib/reasoning-effort.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue