MoA preset autosave corrupts settings with incomplete slots

NousResearch/hermes-agent · cluster 63658

Data as of

Nothing open on GitHub. Fix merged in #64976. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #64976. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #64976. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple bug reports and fixes address a problem where the MoA preset editor autosaves incomplete slot configurations, causing settings corruption or unexpected defaults. Fixes target both the desktop autosave behavior and API-level validation of incomplete MoA saves.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #64976 fix(moa): reject half-filled MoA saves at the API boundary; hold desktop autosave until slots complete

    merged to main

    files: apps/desktop/src/app/settings/model-settings.test.tsx, apps/desktop/src/app/settings/model-settings.tsx, apps/desktop/src/types/hermes.ts, hermes_cli/moa_config.py, hermes_cli/web_server.py, tests/hermes_cli/test_moa_config.py, tests/hermes_cli/test_web_server.py, web/src/lib/api.ts

    Show the diff
    • apps/desktop/src/app/settings/model-settings.test.tsx
      @@ -186,3 +186,139 @@ describe('ModelSettings', () => {
           expect(await screen.findByText(/still run on/)).toBeTruthy()
         })
       })
      +
      +describe('ModelSettings MoA preset editor', () => {
      +  const moaConfig = () => ({
      +    default_preset: 'default',
      +    active_preset: '',
      +    presets: {
      +      default: {
      +        reference_models: [
      +          { provider: 'nous', model: 'hermes-4' },
      +          { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
      +        ],
      +        aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
      +        reference_temperature: 0,
      +        aggregator_temperature: 0,
      +        max_tokens: 4096,
      +        enabled: true
      +      }
      +    },
      +    reference_models: [
      +      { provider: 'nous', model: 'hermes-4' },
      +      { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
      +    ],
      +    aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
      +    reference_temperature: 0,
      +    aggregator_temperature: 0,
      +    max_tokens: 4096,
      +    enabled: true
      +  })
      +
      +  beforeEach(() => {
      +    getGlobalModelOptions.mockResolvedValue({
      +      providers: [
      +        {
      +          name: 'Nous',
      +          slug: 'nous',
      +          models: ['hermes-4', 'hermes-4-mini'],
      +          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
      +        }
      +      ]
      +    })
      +    getMoaModels.mockResolvedValue(moaConfig())
      +    saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body))
      +  })
      +
      +  async function openReferenceEditor() {
      +    await renderModelSettings()
      +    expect(await screen.findByText('Reference 1')).toBeTruthy()
      +  }
      +
      +  function slotSelects() {
      +    // Combobox order in the MoA section (last 7 on the page): preset select,
      +    // then provider+model per reference (2 refs), then aggregator
      +    // provider+model. Reference 1's pair is therefore at -6 / -5.
      +    const all = screen.getAllByRole('combobox')
      +
      +    return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! }
      +  }
      +
      +  it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => {
      +    vi.useFakeTimers({ shouldAdvanceTime: true })
      +
      +    try {
      +      await openReferenceEditor()
      +
      +      fireEvent.click(slotSelects().ref1Provider)
      +      fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
      +
      +      // Model was cleared by the provider change → config incomplete → the
      +      // debounced autosave must NOT fire, even well past the 600ms window.
      +      await vi.advanceTimersByTimeAsync(2000)
      +      expect(saveMoaModels).not.toHaveBeenCalled()
      +    } finally {
      +      vi.useRealTimers()
      +    }
      +  })
      +
      +  it('saves once the model pick completes the slot', async () => {
      +    vi.useFakeTimers({ shouldAdvanceTime: true })
      +
      +    try {
      +      await openReferenceEditor()
      +
      +      fireEvent.click(slotSelects().ref1Provider)
      +      fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
      +      await vi.advanceTimersByTimeAsync(700)
      +
      +      fireEvent.click(slotSelects().ref1Model)
      +      fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' }))
      +      await vi.advanceTimersByTimeAsync(700)
      +
      +      expect(saveMoaModels).toHaveBeenCalledTimes(1)
      +      const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig>
      +      expect(sent.presets.default.reference_models[0]).toEqual({
      +        provider: 'openrouter',
      +        model: 'anthropic/claude-opus-4.8'
      +      })
      +      // The untouched slots ride along unchanged — nothing reverts to defaults.
      +      expect(sent.presets.default.reference_models[1]).toEqual({
      +        provider: 'openrouter',
      +        model: 'deepseek/deepseek-v4-pro'
      +      })
      +      expect(sent.presets.default.aggregator).toEqual({
      +        provider: 'openrouter',
      +        model: 'anthropic/claude-opus-4.8'
      +      })
      +    } finally {
      +      vi.useRealTimers()
      +    }
      +  })
      +
      +  it('does not clear the model or save when the same provider is re-selected', async () => {
      +    vi.useFakeTimers({ shouldAdvanceTime: true })
      +
      +    try {
      +      await openReferenceEditor()
      +
      +      fireEvent.click(slotSelects().ref1Provider)
      +      fireEvent.click(await screen.findByRole('option', { name: 'Nous' }))
      +      await vi.advanceTimersByTimeAsync(700)
      +
      +      // Radix treats re-picking the current value as a no-op (no
      +      // onValueChange), so nothing changes: no save, model still shown.
      +      expect(saveMoaModels).not.toHaveBeenCalled()
      +      expect(screen.getByText('nous · hermes-4')).toBeTruthy()
      +    } finally {
      +      vi.useRealTimers()
      +    }
      +  })
      +})
    • apps/desktop/src/app/settings/model-settings.tsx
      @@ -130,6 +130,24 @@ const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', m
       export const withActive = (models: readonly string[], active: string): readonly string[] =>
         active && !models.includes(active) ? [active, ...models] : models
       
      +// A slot is complete when both halves are chosen. Changing a slot's provider
      +// intentionally clears its model (see updateMoaSlot), so every provider change
      +// passes through an incomplete state while the user picks the new model.
      +export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim())
      +
      +// True when every slot in every preset is fully specified — the only state
      +// that is safe to persist. The backend rejects configs with half-filled slots
      +// (HTTP 422) instead of silently swapping the preset for hardcoded defaults
      +// (#64156), so the autosave must simply wait for the edit to finish rather
      +// than trying to "repair" the payload.
      +export const moaConfigComplete = (config: MoaConfigResponse): boolean =>
      +  Object.values(config.presets).every(
      +    preset =>
      +      preset.reference_models.length > 0 &&
      +      preset.reference_models.every(moaSlotComplete) &&
      +      moaSlotComplete(preset.aggregator)
      +  )
      +
       interface StaleAuxWarningProps {
         applying: boolean
         onReset: () => void
      @@ -317,19 +335,44 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
           []
         )
       
      +  // Guard against stale save responses overwriting newer state.
      +  const moaSaveGeneration = useRef(0)
      +
         // Quiet debounced persist for inline MoA edits — mirrors the config page's
         // autosave so slot/aggregator tweaks save themselves, matching the
         // preset-level ops (set default / add / delete) that already persist on
         // click. No `applying` spinner, so selecting stays responsive.
      +  //
      +  // While any slot is half-filled (provider picked, model pending) the save is
      +  // HELD, not sent: the previous complete config stays on disk and the next
      +  // edit that completes the slot flushes the whole preset. Every edit bumps
      +  // the generation so an in-flight response from an older save can never
      +  // repaint over the user's mid-edit state.
         const scheduleMoaSave = useCallback((next: MoaConfigResponse) => {
           if (moaSaveTimer.current) {
             window.clearTimeout(moaSaveTimer.current)
      +      moaSaveTimer.current = null
      +    }
      +
      +    const generation = moaSaveGeneration.current + 1
      +    moaSaveGeneration.current = generation
      +
      +    if (!moaConfigComplete(next)) {
      +      return
           }
       
           moaSaveTimer.current = window.setTimeout(() => {
             void saveMoaModels(next)
      -        .then(setMoa)
      -        .catch(err => setError(err instanceof Error ? err.message : String(err)))
      +        .then(saved => {
      +          if (moaSaveGeneration.current === generation) {
      +            setMoa(saved)
      +          }
      +        })
      +        .catch(err => {
      +          if (moaSaveGeneration.current === generation) {
      +            setError(err instanceof Error ? err.message : String(err))
      +          }
      +        })
           }, 600)
         }, [])
       
      @@ -359,7 +402,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
         const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {
           const next = { ...slot, ...patch }
       
      -    if (patch.provider) {
      +    // Picking a new provider invalidates the model choice (models are
      +    // per-provider). A same-provider update must not wipe the model — Radix
      +    // filters same-value changes, but programmatic callers may not.
      +    if (patch.provider && patch.provider !== slot.provider) {
             next.model = ''
           }
       
      @@ -368,6 +414,16 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
       
         const saveMoa = useCallback(async (next: MoaConfigResponse) => {
           const epoch = profileEpoch.current
      +
      +    // Explicit preset ops (set default / add / delete) supersede any pending
      +    // debounced slot autosave — cancel it and invalidate in-flight responses
      +    // so the two writers can't race each other's state.
      +    if (moaSaveTimer.current) {
      +      window.clearTimeout(moaSaveTimer.current)
      +      moaSaveTimer.current = null
      +    }
      +
      +    moaSaveGeneration.current += 1
           setApplying(true)
           setError('')
       
      @@ -991,11 +1047,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
                               <SelectValue placeholder={m.provider} />
                             </SelectTrigger>
                             <SelectContent>
      -                        {moaSlotProviderOptions.map(provider => (
      -                          <SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
      -                            {provider.name}
      -                          </SelectItem>
      -                        ))}
      +                        {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {
      +                          const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
      +
      +                          return (
      +                            <SelectItem key={slug} value={slug}>
      +                              {provider?.name || slug}
      +                            </SelectItem>
      +                          )
      +                        })}
                             </SelectContent>
                           </Select>
                           <Select
      @@ -1037,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
                       }
                       description={
                         <span className="font-mono text-[0.68rem]">
      -                    {slot.provider} · {slot.model}
      +                    {slot.provider} · {slot.model || m.model}
                         </span>
                       }
      -                key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`}
      +                key={`${selectedMoaPreset}-${index}`}
                       title={`Reference ${index + 1}`}
                     />
                   ))}
      @@ -1070,11 +1130,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
                             <SelectValue placeholder={m.provider} />
                           </SelectTrigger>
                           <SelectContent>
      -                      {moaSlotProviderOptions.map(provider => (
      -                        <SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
      -                          {provider.name}
      -                        </SelectItem>
      -                      ))}
      +                      {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {
      +                        const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
      +
      +                        return (
      +                          <SelectItem key={slug} value={slug}>
      +                            {provider?.name || slug}
      +                          </SelectItem>
      +                        )
      +                      })}
                           </SelectContent>
                         </Select>
                         <Select
    • apps/desktop/src/types/hermes.ts
      @@ -857,6 +857,8 @@ export interface AuxiliaryModelsResponse {
       export interface MoaModelSlot {
         provider: string
         model: string
      +  /** Optional per-slot reasoning effort — round-tripped, not edited here. */
      +  reasoning_effort?: string
       }
       
       export interface MoaConfigResponse {
      @@ -871,6 +873,10 @@ export interface MoaConfigResponse {
             max_tokens: number
             reference_models: MoaModelSlot[]
             reference_temperature: number
      +      /** Optional advisor output cap — round-tripped, not edited here. */
      +      reference_max_tokens?: number | null
      +      /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */
      +      fanout?: string
           }
         >
         aggregator: MoaModelSlot
    • hermes_cli/moa_config.py
      @@ -110,6 +110,77 @@ def _clean_slot(slot: Any) -> dict[str, Any] | None:
           return clean
       
       
      +def _slot_problem(slot: Any) -> str | None:
      +    """Return a human-readable problem for a slot ``_clean_slot`` would drop.
      +
      +    None means the slot is complete and valid. Mirrors ``_clean_slot`` exactly
      +    so the write-boundary validator (``validate_moa_payload``) and the
      +    tolerant runtime normalizer can never disagree about what is acceptable.
      +    """
      +    if not isinstance(slot, dict):
      +        return "must be an object with 'provider' and 'model'"
      +    provider = str(slot.get("provider") or "").strip()
      +    model = str(slot.get("model") or "").strip()
      +    if not provider and not model:
      +        return "provider and model are required"
      +    if not provider:
      +        return "provider is required"
      +    if not model:
      +        return f"model is required (provider '{provider}' has no model selected)"
      +    if provider.lower() == "moa":
      +        return "the Mixture of Agents provider cannot be used inside a preset (recursive MoA)"
      +    return None
      +
      +
      +def validate_moa_payload(raw: Any) -> list[str]:
      +    """Return the problems ``normalize_moa_config`` would silently paper over.
      +
      +    ``normalize_moa_config`` is deliberately tolerant: at *read* time a
      +    hand-edited config must degrade to defaults rather than crash the agent.
      +    That same tolerance at *write* time is a corruption engine — a client that
      +    sends a half-filled slot gets its whole preset silently replaced with the
      +    hardcoded defaults (#64156). API write paths call this first and reject
      +    invalid payloads loudly instead of saving something the user never chose.
      +
      +    Returns a list of human-readable problems; empty means safe to save.
      +    """
      +    if not isinstance(raw, dict):
      +        return ["MoA config must be an object"]
      +
      +    presets_raw = raw.get("presets")
      +    if isinstance(presets_raw, dict) and presets_raw:
      +        presets: dict[Any, Any] = presets_raw
      +    else:
      +        # Legacy flat payload: the top-level object is the default preset.
      +        presets = {DEFAULT_MOA_PRESET_NAME: raw}
      +
      +    problems: list[str] = []
      +    for name, preset in presets.items():
      +        label = str(name or "").strip() or "(unnamed)"
      +        if not isinstance(preset, dict):
      +            problems.append(f"preset '{label}': must be an object")
      +            continue
      +
      +        refs = preset.get("reference_models")
      +        if not isinstance(refs, list):
      +            refs = [refs] if isinstance(refs, dict) else []
      +        complete_refs = 0
      +        for index, slot in enumerate(refs):
      +            issue = _slot_problem(slot)
      +            if issue:
      +                problems.append(f"preset '{label}' reference {index + 1}: {issue}")
      +            else:
      +                complete_refs += 1
      +        if not complete_refs:
      +            problems.append(f"preset '{label}': needs at least one complete reference model")
      +
      +        agg_issue = _slot_problem(preset.get("aggregator"))
      +        if agg_issue:
      +            problems.append(f"preset '{label}' aggregator: {agg_issue}")
      +
      +    return problems
      +
      +
       def _default_preset() -> dict[str, Any]:
           return {
               "reference_models": deepcopy(DEFAULT_MOA_REFERENCE_MODELS),
    • hermes_cli/web_server.py
      @@ -996,6 +996,9 @@ class ModelAssignment(BaseModel):
       class MoaModelSlot(BaseModel):
           provider: str = ""
           model: str = ""
      +    # Optional per-slot reasoning effort. Declared so a client round-tripping
      +    # the GET payload doesn't have it stripped at parse time and wiped on save.
      +    reasoning_effort: Optional[str] = None
       
       
       class MoaPresetPayload(BaseModel):
      @@ -1006,6 +1009,11 @@ class MoaPresetPayload(BaseModel):
           reference_temperature: Optional[float] = None
           aggregator_temperature: Optional[float] = None
           max_tokens: int = 4096
      +    # Newer per-preset knobs (see moa_config._normalize_preset). Optional so
      +    # older clients that never send them keep working; declared so clients
      +    # that round-trip the GET payload don't silently erase hand-set values.
      +    reference_max_tokens: [redacted:secret-assignment] = None
      +    fanout: Optional[str] = None
           enabled: bool = True
       
       
      @@ -1020,6 +1028,8 @@ class MoaConfigPayload(BaseModel):
           reference_temperature: Optional[float] = None
           aggregator_temperature: Optional[float] = None
           max_tokens: int = 4096
      +    reference_max_tokens: [redacted:secret-assignment] = None
      +    fanout: Optional[str] = None
           enabled: bool = True
           profile: Optional[str] = None
       
      @@ -5701,7 +5711,23 @@ def get_moa_models(profile: Optional[str] = None):
       def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):
           """Persist the Mixture-of-Agents provider/model slots."""
           try:
      -        from hermes_cli.moa_config import normalize_moa_config
      +        from hermes_cli.moa_config import normalize_moa_config, validate_moa_payload
      +
      +        def _slot_dict(slot: MoaModelSlot) -> dict:
      +            # Drop unset optionals so saved slots stay minimal ({provider, model}).
      +            return {k: v for k, v in slot.dict().items() if v is not None}
      +
      +        def _preset_dict(preset: MoaPresetPayload) -> dict:
      +            return {
      +                "reference_models": [_slot_dict(slot) for slot in preset.reference_models],
      +                "aggregator": _slot_dict(preset.aggregator),
      +                "reference_temperature": preset.reference_temperature,
      +                "aggregator_temperature": preset.aggregator_temperature,
      +                "max_tokens": preset.max_tokens,
      +                "reference_max_tokens": preset.reference_max_tokens,
      +                "fanout": preset.fanout,
      +                "enabled": preset.enabled,
      +            }
       
               with _profile_scope(body.profile or profile):
                   cfg = load_config()
      @@ -5709,27 +5735,35 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):
                       raw = {
                           "default_preset": body.default_preset,
                           "active_preset": body.active_preset,
      -                    "presets": {
      -                        name: {
      -                            "reference_models": [slot.dict() for slot in preset.reference_models],
      -                            "aggregator": preset.aggregator.dict(),
      -                            "reference_temperature": preset.reference_temperature,
      -                            "aggregator_temperature": preset.aggregator_temperature,
      -                            "max_tokens": preset.max_tokens,
      -                            "enabled": preset.enabled,
      -                        }
      -                        for name, preset in body.presets.items()
      -                    },
      +                    "presets": {name: _preset_dict(preset) for name, preset in body.presets.items()},
                       }
                   else:
      -                raw = {
      -                    "reference_models": [slot.dict() for slot in body.reference_models],
      -                    "aggregator": body.aggregator.dict(),
      -                    "reference_temperature": body.reference_temperature,
      -                    "aggregator_temperature": body.aggregator_temperature,
      -                    "max_tokens": body.max_tokens,
      -                    "enabled": body.enabled,
      -                }
      +                raw = _preset_dict(
      +                    MoaPresetPayload(
      +                        reference_models=body.reference_models,
      +                        aggregator=body.aggregator,
      +                        reference_temperature=body.reference_temperature,
      +                        aggregator_temperature=body.aggregator_temperature,
      +                        max_tokens=[redacted:secret-assignment]
      +                        reference_max_tokens=[redacted:secret-assignment]
      +                        fanout=body.fanout,
      +                        enabled=body.enabled,
      +                    )
      +                )
      +
      +            # Reject-don't-repair: normalize_moa_config() silently swaps any
      +            # preset containing incomplete slots for the hardcoded defaults —
      +            # correct tolerance for hand-edited configs at READ time, silent
      +            # data loss at WRITE time (#64156: desktop autosave of a
      +            # half-filled slot replaced the user's whole preset). Refuse the
      +            # save loudly so no client can corrupt config through this route.
      +            problems = validate_moa_payload(raw)
      +            if problems:
      +                raise HTTPException(
      +                    status_code=422,
      +                    detail="Invalid MoA config: " + "; ".join(problems),
      +                )
      +
                   normalized = normalize_moa_config(raw)
                   cfg["moa"] = normalized
                   save_config(cfg)
    • tests/hermes_cli/test_moa_config.py
      @@ -304,3 +304,115 @@ def test_reference_max_tokens_in_flattened_view():
           active preset's reference_max_tokens."""
           cfg = normalize_moa_config(_preset(reference_max_tokens=750))
           assert cfg["reference_max_tokens"] == 750
      +
      +
      +# ── validate_moa_payload (write-boundary validation, #64156) ─────────────────
      +#
      +# normalize_moa_config is deliberately tolerant at READ time (hand-edited
      +# configs degrade to defaults). validate_moa_payload is the strict WRITE-time
      +# counterpart: it must flag exactly the payloads normalize would silently
      +# repair, so API save paths reject them instead of corrupting user config.
      +
      +
      +def _valid_preset_payload():
      +    return {
      +        "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
      +        "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
      +    }
      +
      +
      +def test_validate_moa_payload_accepts_complete_presets():
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    assert validate_moa_payload({"presets": {"default": _valid_preset_payload()}}) == []
      +
      +
      +def test_validate_moa_payload_accepts_legacy_flat_payload():
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    assert validate_moa_payload(_valid_preset_payload()) == []
      +
      +
      +def test_validate_moa_payload_flags_half_filled_reference_slot():
      +    """The #64156 shape: provider picked, model still empty (mid-edit autosave)."""
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    preset = _valid_preset_payload()
      +    preset["reference_models"].append({"provider": "kilo", "model": ""})
      +    problems = validate_moa_payload({"presets": {"default": preset}})
      +
      +    assert problems
      +    assert any("reference 2" in p and "model is required" in p for p in problems)
      +
      +
      +def test_validate_moa_payload_flags_half_filled_aggregator():
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    preset = _valid_preset_payload()
      +    preset["aggregator"] = {"provider": "openrouter", "model": ""}
      +    problems = validate_moa_payload({"presets": {"default": preset}})
      +
      +    assert any("aggregator" in p and "model is required" in p for p in problems)
      +
      +
      +def test_validate_moa_payload_flags_empty_references():
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    preset = _valid_preset_payload()
      +    preset["reference_models"] = []
      +    problems = validate_moa_payload({"presets": {"default": preset}})
      +
      +    assert any("at least one complete reference model" in p for p in problems)
      +
      +
      +def test_validate_moa_payload_flags_recursive_moa_slot():
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    preset = _valid_preset_payload()
      +    preset["aggregator"] = {"provider": "MoA", "model": "default"}
      +    problems = validate_moa_payload({"presets": {"default": preset}})
      +
      +    assert any("recursive MoA" in p for p in problems)
      +
      +
      +def test_validate_moa_payload_names_the_broken_preset():
      +    """Multi-preset payloads must say WHICH preset is broken."""
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    problems = validate_moa_payload(
      +        {
      +            "presets": {
      +                "good": _valid_preset_payload(),
      +                "broken": {
      +                    "reference_models": [{"provider": "", "model": ""}],
      +                    "aggregator": {"provider": "a", "model": "b"},
      +                },
      +            }
      +        }
      +    )
      +
      +    assert problems
      +    assert all("'broken'" in p for p in problems)
      +    assert not any("'good'" in p for p in problems)
      +
      +
      +def test_validate_moa_payload_agrees_with_clean_slot():
      +    """Contract: a payload validate accepts must survive normalize UNCHANGED in
      +    its slots — validate and _clean_slot can never disagree (else a payload
      +    could pass validation and still be swapped for defaults)."""
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    payload = {"presets": {"p": _valid_preset_payload()}}
      +    assert validate_moa_payload(payload) == []
      +
      +    cfg = normalize_moa_config(payload)
      +    assert cfg["presets"]["p"]["reference_models"] == payload["presets"]["p"]["reference_models"]
      +    assert cfg["presets"]["p"]["aggregator"] == payload["presets"]["p"]["aggregator"]
      +
      +
      +def test_validate_moa_payload_rejects_non_dict():
      +    from hermes_cli.moa_config import validate_moa_payload
      +
      +    assert validate_moa_payload(None)
      +    assert validate_moa_payload([1, 2])
      +    assert validate_moa_payload({"presets": {"p": "not-a-dict"}})
    • tests/hermes_cli/test_web_server.py
      @@ -861,6 +861,72 @@ def test_put_moa_models_persists_provider_model_slots(self):
               assert cfg["moa"]["reference_models"] == payload["reference_models"]
               assert cfg["moa"]["aggregator"] == payload["aggregator"]
       
      +    def test_put_moa_models_rejects_half_filled_slot_with_422(self):
      +        """#64156: a mid-edit autosave (provider picked, model empty) used to be
      +        silently normalized into the hardcoded default preset — the user's
      +        config was replaced without any error. The write path must reject it."""
      +        from hermes_cli.config import load_config
      +
      +        original = load_config().get("moa")
      +
      +        payload = {
      +            "presets": {
      +                "default": {
      +                    "reference_models": [{"provider": "kilo", "model": ""}],
      +                    "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
      +                }
      +            }
      +        }
      +
      +        resp = self.client.put("/api/model/moa", json=payload)
      +        assert resp.status_code == 422
      +        assert "model is required" in resp.json()["detail"]
      +        # Config untouched — not swapped for defaults.
      +        assert load_config().get("moa") == original
      +
      +    def test_put_moa_models_rejects_half_filled_aggregator_with_422(self):
      +        payload = {
      +            "presets": {
      +                "default": {
      +                    "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
      +                    "aggregator": {"provider": "openrouter", "model": ""},
      +                }
      +            }
      +        }
      +
      +        resp = self.client.put("/api/model/moa", json=payload)
      +        assert resp.status_code == 422
      +        assert "aggregator" in resp.json()["detail"]
      +
      +    def test_put_moa_models_round_trips_fanout_and_reference_max_tokens(self):
      +        """GET → PUT round-trip must not erase newer per-preset knobs. The old
      +        Pydantic payload didn't declare fanout / reference_max_tokens, so any
      +        client save silently wiped hand-set values back to defaults."""
      +        from hermes_cli.config import load_config
      +
      +        payload = {
      +            "presets": {
      +                "default": {
      +                    "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
      +                    "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
      +                    "fanout": "user_turn",
      +                    "reference_max_tokens": 600,
      +                }
      +            }
      +        }
      +
      +        resp = self.client.put("/api/model/moa", json=payload)
      +        assert resp.status_code == 200
      +
      +        saved = load_config()["moa"]["presets"]["default"]
      +        assert saved["fanout"] == "user_turn"
      +        assert saved["reference_max_tokens"] == 600
      +
      +        # And the GET view carries them back to the client.
      +        fetched = self.client.get("/api/model/moa").json()
      +        assert fetched["presets"]["default"]["fanout"] == "user_turn"
      +        assert fetched["presets"]["default"]["reference_max_tokens"] == 600
      +
           # ── GET /api/media (remote image display) ───────────────────────────
       
           def test_get_media_serves_image_in_root(self):
    • web/src/lib/api.ts
      @@ -2301,6 +2301,8 @@ export interface AuxiliaryModelsResponse {
       export interface MoaModelSlot {
         provider: string;
         model: string;
      +  /** Optional per-slot reasoning effort — round-tripped, not edited here. */
      +  reasoning_effort?: string;
       }
       
       export interface MoaConfigResponse {
      @@ -2312,6 +2314,10 @@ export interface MoaConfigResponse {
           reference_temperature: number;
           aggregator_temperature: number;
           max_tokens: [redacted:secret-assignment]
      +    /** Optional advisor output cap — round-tripped, not edited here. */
      +    reference_max_tokens?: number | null;
      +    /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */
      +    fanout?: string;
           enabled: boolean;
         }>;
         reference_models: MoaModelSlot[];

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #63867 fix(desktop): preserve incomplete MoA edits · done elsewhere → #64976, #64158, #63658
  • #64158 fix(desktop): prevent MoA autosave defaults explosion from half-filled slots · merged via another change → a61a0bc, #64976
Closed issues · 3
  • Closed#63658[Bug]: MoA reference disappears when changing provider because autosave persists an incomplete slot
  • Closed#64156[Bug]: MoA preset editor — changing reference provider triggers defaults explosion from half-filled autosave
  • Closed#64385[Bug]: Desktop MoA incomplete-slot autosave corrupts settings; hermes update on Windows wipes local fix and reintroduces bug
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#64976
Closed issues
3
Attempts
2 PRs closed without merging
Fix link checked
15 Jul 2026

For your agent

The same facts, typed.