Skip to content
Closed
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
8 changes: 6 additions & 2 deletions agent/moa_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ def _run_reference(


def _run_references_parallel(
reference_models: list[dict[str, str]],
reference_models: list[dict[str, Any]],
ref_messages: list[dict[str, Any]],
*,
temperature: float | None = None,
Expand Down Expand Up @@ -594,6 +594,7 @@ def aggregate_moa_context(
provider default applies β€” matching single-model agent behavior. Presets

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a direct aggregate_moa_context() test for this filter. The new runtime test covers persistent MoAChatCompletions.create(), but one-shot /moa reaches this separate path through agent/conversation_loop.py:877-893.

may still pin explicit values.
"""
reference_models = [slot for slot in reference_models if slot.get("enabled", True)]
reference_outputs: list[tuple[str, str, Any]] = []
ref_messages = _reference_messages(api_messages)
reference_outputs = _run_references_parallel(
Expand Down Expand Up @@ -803,7 +804,10 @@ def create(self, **api_kwargs: Any) -> Any:

preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name)
messages = list(api_kwargs.get("messages") or [])
reference_models = preset.get("reference_models") or []
reference_models = [
slot for slot in (preset.get("reference_models") or [])
if slot.get("enabled", True)
]
aggregator = preset.get("aggregator") or {}
# Expose the resolved aggregator slot so session cost accounting can
# price the aggregator's acting turn at its REAL model/provider. The
Expand Down
81 changes: 75 additions & 6 deletions apps/desktop/src/app/settings/model-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

Expand All @@ -16,6 +17,9 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const getMoaModels = vi.fn()
const saveMoaModels = vi.fn()
const setApiRequestProfile = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn()
Expand All @@ -27,6 +31,9 @@ vi.mock('@/hermes', () => ({
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getMoaModels: () => getMoaModels(),
saveMoaModels: (body: unknown) => saveMoaModels(body),
setApiRequestProfile: (profile: string | null) => setApiRequestProfile(profile),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
}))
Expand All @@ -46,6 +53,12 @@ beforeEach(() => {
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
{
name: 'OpenRouter',
slug: 'openrouter',
models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'],
authenticated: true
},
// An unconfigured api_key provider β€” surfaced by the full-universe payload.
{
name: 'DeepSeek',
Expand All @@ -64,6 +77,33 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getMoaModels.mockResolvedValue({
default_preset: 'review',
active_preset: '',
presets: {
review: {
reference_models: [
{ provider: 'nous', model: 'hermes-4', enabled: true },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro', enabled: true }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: null,
aggregator_temperature: null,
max_tokens: 4096,
enabled: true
}
},
reference_models: [
{ provider: 'nous', model: 'hermes-4', enabled: true },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro', enabled: true }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: null,
aggregator_temperature: null,
max_tokens: 4096,
enabled: true
})
saveMoaModels.mockImplementation(async body => ({ ...(body as object), ok: true }))
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
Expand All @@ -75,8 +115,13 @@ afterEach(() => {

async function renderModelSettings() {
const { ModelSettings } = await import('./model-settings')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })

return render(<ModelSettings />)
return render(
<QueryClientProvider client={client}>
<ModelSettings />
</QueryClientProvider>
)
}

describe('ModelSettings', () => {
Expand All @@ -91,11 +136,10 @@ describe('ModelSettings', () => {
const triggers = await screen.findAllByRole('combobox')
fireEvent.click(triggers[0])

// "Nous" shows in both the trigger and the open list; the unconfigured
// provider + its setup hint are the unique signal of the full universe.
// "Nous" shows in both the trigger and the open list; DeepSeek is the
// unique signal that the unconfigured provider universe is present.
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
expect(await screen.findByText(/set up/)).toBeTruthy()
})

it('activates an unconfigured api_key provider inline by saving its key', async () => {
Expand Down Expand Up @@ -123,7 +167,7 @@ describe('ModelSettings', () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())

const fastSwitch = await screen.findByRole('switch')
const fastSwitch = await screen.findByText('Fast')
fireEvent.click(fastSwitch)

await waitFor(() =>
Expand All @@ -149,7 +193,7 @@ describe('ModelSettings', () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())

expect(screen.queryByRole('switch')).toBeNull()
expect(screen.queryByText('Fast')).toBeNull()
})

it('renders the auxiliary task rows', async () => {
Expand All @@ -159,6 +203,31 @@ describe('ModelSettings', () => {
expect(screen.getAllByText('auto Β· use main model').length).toBeGreaterThan(0)
})

it('saves disabled MoA reference models without removing them', async () => {
await renderModelSettings()

const referenceSwitch = await screen.findByRole('switch', { name: 'Disable reference 1' })
fireEvent.click(referenceSwitch)

const saveButton = await screen.findByRole('button', { name: 'Save' })
fireEvent.click(saveButton)

await waitFor(() =>
expect(saveMoaModels).toHaveBeenCalledWith(
expect.objectContaining({
presets: expect.objectContaining({
review: expect.objectContaining({
reference_models: [
expect.objectContaining({ provider: 'nous', model: 'hermes-4', enabled: false }),
expect.objectContaining({ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro', enabled: true })
]
})
})
})
)
)
})

it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
await renderModelSettings()

Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/src/app/settings/model-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,21 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<div className="grid gap-1">
{currentMoaPreset.reference_models.map((slot, index) => (
<ListRow
action={
<Switch
aria-label={`${slot.enabled !== false ? 'Disable' : 'Enable'} reference ${index + 1}`}
checked={slot.enabled !== false}
disabled={applying}
onCheckedChange={checked =>
updateMoaPreset(prev => ({
...prev,
reference_models: prev.reference_models.map((s, i) =>
i === index ? { ...s, enabled: checked === true } : s
)
}))
}
/>
}
below={
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
<Select
Expand Down Expand Up @@ -1001,6 +1016,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
</Button>
</div>
}
className={cn(slot.enabled === false && 'opacity-60')}
description={
<span className="font-mono text-[0.68rem]">
{slot.provider} Β· {slot.model}
Expand All @@ -1013,7 +1029,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<Button
disabled={applying}
onClick={() =>
updateMoaPreset(prev => ({ ...prev, reference_models: [...prev.reference_models, prev.aggregator] }))
updateMoaPreset(prev => ({
...prev,
reference_models: [...prev.reference_models, { ...prev.aggregator, enabled: true }]
}))
}
size="sm"
variant="textStrong"
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/app/settings/primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,22 @@ export function ListRow({
hint,
action,
below,
wide = false
wide = false,
className
}: {
title: ReactNode
description?: ReactNode
hint?: ReactNode
action?: ReactNode
below?: ReactNode
wide?: boolean
className?: string
}) {
return (
// Container-queried, not viewport-queried: the label/control split keys on
// the row's own pane width, so a narrow detail column (messaging, split
// views) stacks instead of squishing the label against minmax(15rem,…).
<div className="@container">
<div className={cn('@container', className)}>
<div
className={cn(
'grid gap-3 py-3',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,7 @@ export interface AuxiliaryModelsResponse {
export interface MoaModelSlot {
provider: string
model: string
enabled?: boolean
}

export interface MoaConfigResponse {
Expand Down
4 changes: 3 additions & 1 deletion hermes_cli/moa_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ def cmd_moa(args) -> None:
idx = 0
while True:
base = existing[idx] if idx < len(existing) else None
refs.append(_pick_slot(base))
picked = _pick_slot(base)
picked["enabled"] = bool((base or {}).get("enabled", True))
refs.append(picked)
idx += 1
choice = _prompt_choice("Add another reference model?", ["Add another", "Done"], 1)
if choice == 1:
Expand Down
34 changes: 28 additions & 6 deletions hermes_cli/moa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
}


def _default_reference_models() -> list[dict[str, Any]]:
return [{**slot, "enabled": True} for slot in deepcopy(DEFAULT_MOA_REFERENCE_MODELS)]


def _coerce_float_or_none(value: Any) -> float | None:
"""Coerce to a float, or None when unset/blank/invalid.

Expand Down Expand Up @@ -73,7 +77,22 @@ def _coerce_fanout(value: Any) -> str:
return mode if mode in {"per_iteration", "user_turn"} else "per_iteration"


def _clean_slot(slot: Any) -> dict[str, str] | None:
def _coerce_bool(value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
text = value.strip().lower()
if text in {"0", "false", "no", "off"}:
return False
if text in {"1", "true", "yes", "on"}:
return True
return default
return bool(value)


def _clean_slot(slot: Any, *, include_enabled: bool = False) -> dict[str, Any] | None:
if not isinstance(slot, dict):
return None
provider = str(slot.get("provider") or "").strip()
Expand All @@ -87,12 +106,15 @@ def _clean_slot(slot: Any) -> dict[str, str] | None:
# an invalid slot is dropped, falling back to the preset's defaults.
if provider.lower() == "moa":
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main's _clean_slot preserves reasoning_effort (3dca75b45c2edf38089ef07c22023afe357aebc1, now hermes_cli/moa_config.py:107-109). When salvaging, compose enabled normalization with that behavior rather than replacing it, or normalization will discard existing per-slot reasoning settings.

return {"provider": provider, "model": model}
clean: dict[str, Any] = {"provider": provider, "model": model}
if include_enabled:
clean["enabled"] = _coerce_bool(slot.get("enabled"), True)
return clean


def _default_preset() -> dict[str, Any]:
return {
"reference_models": deepcopy(DEFAULT_MOA_REFERENCE_MODELS),
"reference_models": _default_reference_models(),
"aggregator": deepcopy(DEFAULT_MOA_AGGREGATOR),
# None = temperature omitted from API calls (provider default),
# matching single-model agent behavior.
Expand All @@ -115,15 +137,15 @@ def _normalize_preset(raw: Any) -> dict[str, Any]:
# defaults instead of crashing the iteration, mirroring the tolerance
# for the scalar fields below (reference_temperature / max_tokens).
raw_refs = [raw_refs] if isinstance(raw_refs, dict) else []
refs = [_clean_slot(item) for item in raw_refs]
refs = [_clean_slot(item, include_enabled=True) for item in raw_refs]
refs = [item for item in refs if item is not None]
if not refs:
refs = deepcopy(DEFAULT_MOA_REFERENCE_MODELS)
refs = _default_reference_models()

aggregator = _clean_slot(raw.get("aggregator")) or deepcopy(DEFAULT_MOA_AGGREGATOR)

return {
"enabled": bool(raw.get("enabled", True)),
"enabled": _coerce_bool(raw.get("enabled"), True),
"reference_models": refs,
"aggregator": aggregator,
"reference_temperature": _coerce_float_or_none(raw.get("reference_temperature")),
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ class ModelAssignment(BaseModel):
class MoaModelSlot(BaseModel):
provider: str = ""
model: str = ""
enabled: bool = True


class MoaPresetPayload(BaseModel):
Expand Down
4 changes: 3 additions & 1 deletion tests/cli/test_moa_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,6 @@ def test_decode_legacy_encoded_moa_turn_still_works():
encoded = build_moa_turn_prompt("hello", _make_cli().config["moa"], preset="review")
prompt, cfg = decode_moa_turn(encoded)
assert prompt == "hello"
assert cfg["reference_models"] == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}]
assert cfg["reference_models"] == [
{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro", "enabled": True}
]
Loading