Repository navigation
fix(agent): stop /model calling a cloud model local in the switch message - #4365
Conversation
…sage
Switching to a cloud model whose id prefix is not `fireworks.` or `amd.`
answered with the LOCAL sentence:
Switched to **custom.gemma-4-31b-it**. Chat inference runs through
Lemonade on this machine, model `custom.gemma-4-31b-it`. GAIA is not
sending this conversation to any cloud provider.
That model is served by Fireworks. The user gets an affirmative privacy
assurance that is false, and the system prompt — which does consult the
client — then says the opposite, which is the contradiction #3674 set out
to remove.
The switchable-model filter calls `cloud_model_provider(id, metadata)` and
honours the catalog's own `cloud_provider`, so a model is switchable under
any prefix. The switch message called the one-argument form, which knows
only the two hard-coded prefixes and returns None for everything else —
and None means local. It now passes the live client's classifier, exactly
as `ChatAgent._inference_location` already does.
The regression test drives `/model` with a catalog entry whose
`cloud_provider` is `fireworks` behind a `custom.` prefix, and fails on the
old code with the message quoted above.
Request changesThis PR sets out to stop The classifier the fix now consults belongs to the client the switch just created. That client only learns which models are cloud-hosted after it has fetched the catalog, and nothing between creating it and composing the message makes it do that — so it answers "not a cloud model" for exactly the case this PR targets, and the same local-sounding sentence goes out as before. The catalog was read moments earlier, by the throwaway client that validated the model id; carrying that answer forward (or warming the new client once before the message is composed) is what would close the gap. Warming it would also cover the system prompt, which is rebuilt during the same switch from the same cold client and so likely repeats the claim — worth checking, because if so this isn't a stdio-only gap and the PR's "the prompt already says the opposite" framing needs revisiting. The test's stub answers "Fireworks" unconditionally, which the real client would only do after reading the catalog. That's the pattern CLAUDE.md calls out from #1655: the stub proves the call was made, not that it returns anything useful. Whatever the fix ends up being, the test needs a stand-in that starts cold like the real one. Real-world evidenceThe evidence stage ran the changed path against a real Lemonade HTTP client and reports the fix is a no-op for its target case — output byte-identical before and after: Same events on PR HEAD and on its parent. The known-prefix case ( Spot regression on adjacent operations was clean: no-arg 🔍 Technical details🔴 The fix doesn't change the behaviour it targets (
|
The previous fix passed the newly switched client's cloud_model_provider into the switch message, but that client had never called list_models(), so its catalog metadata was empty and it answered "not a cloud model" for exactly the case being fixed — a no-op confirmed against a real Lemonade client. The system prompt, rebuilt from the same client immediately after a switch, had the identical gap. _apply_local_switch now reads the catalog once (LemonadeProvider. refresh_model_catalog) right after building the new client and before handing it to _apply_switch, so both the switch message and the rebuilt system prompt classify a runtime-discovered cloud model correctly. The regression test now starts its stub client cold, like the real one, and only classifies after its own refresh_model_catalog call — the old version answered correctly from construction and could not have caught this.
Skill audit
✅ All audited skills cleared the tier they claim. Per-finding detail is withheld here on purpose. Read it in the Security > Code scanning tab, or download the |
|
The earlier review was right, and the fix it asked for landed in The three points from that review, each resolved:
🔍 Verification and CI triageThe regression test is realRemoving the Restored, everything green: Ordering confirmed by reading the source: Red checks — all pre-existing on
|
| Check | Cause | Evidence |
|---|---|---|
8 × Scenario eval - *, gaia_* judged categories vs committed baselines |
Repo-wide eval infra | eval_flagship.yml on main has failed 5 consecutive runs since 2026-09-26; last success was 36113705061 on 2026-09-25 |
Unit Tests (Windows smoke) |
test_client_disconnect_cancels_the_agent — a threading race (assert False = wait(timeout=5)), 1 failed / 1948 passed |
Failing on main in run 36576942529. #4421 is open against exactly that file |
Unit Tests (macOS smoke) |
test_memory_turn_recall.py::test_the_relevant_note_reaches_the_turn_with_its_age, 1 failed / 12994 passed |
Failing on main in the same run |
Test Agent SDK on Windows (Lemonade Integration) |
test_performance_integration — live model returned empty response.text |
Passed on this PR's own previous run |
Worth flagging for anyone reading check status on this repo: main run
36576942529 reports a run-level conclusion: success while its job list shows
both Unit Tests (Windows smoke) and Unit Tests (macOS smoke) as failure.
Only Unit Tests (Linux, py3.12) actually passed. Reading the run conclusion
alone gives the wrong answer about whether main is green.
This diff touches three files — stdio.py, test_stdio.py, and
providers/lemonade.py (purely additive: one new method plus a docstring). No
failing test exercises any of them.
Switching to a cloud model in the TUI could tell you your conversation was staying on your machine when it was being sent to Fireworks.
/modelanswered with an affirmative privacy assurance — "GAIA is not sending this conversation to any cloud provider" — for a model it had just routed to the cloud. That contradiction is the exact thing #3674 and #3706 set out to remove; this closes the one path they missed.It only bites when the model's id prefix isn't
fireworks.oramd.— the switchable-model filter accepts any prefix so long as the catalog's owncloud_providerfield names a known provider, but the switch message was classifying from the prefix alone. Both the switch message and the system prompt rebuilt alongside it now read the catalog before they answer.Found while reviewing #3706.
Test plan
pytest hub/agents/gaia/python/tests/test_stdio.py— 79 passedpytest tests/unit/test_inference_location.py— 27 passedpython util/lint.py --black --isort --flake8— 3/3 passedrefresh_model_catalogwarm-up from_apply_local_switchandtest_switch_message_classifies_from_the_client_not_the_id_prefixfails:E assert 0 == 1 E + where 0 = <_CatalogAwareClient object>.refresh_calls🔍 Technical details
The bug
_lemonade_models(stdio.py) filters with the two-argumentcloud_model_provider(m["id"], m), which returnsmetadata.get("cloud_provider") or providerfor arecipe: cloudentry — so anid like
custom.gemma-4-31b-itcarryingcloud_provider: "fireworks"isswitchable.
run_model_commandthen called the one-argument form, which only matchesprovider in {"fireworks", "amd"}and returnsNoneotherwise.Noneis whatresolve_inference_locationreads as local, so the local sentence was emittedfor a cloud session.
Why passing the live client wasn't enough on its own
The first commit passed
cloud_provider_lookupfromagent.chat.llm_client.That client is the one
_apply_local_switchhad just built viacreate_client,and
cloud_model_provideronly recognises a runtime-discovered provider oncelist_models()has populated_model_metadata. Nothing warmed it, so it stillanswered
Nonefor exactly the target case — a no-op, confirmed against a realLemonade client.
The same cold client is handed to
_apply_switch, which callsrebuild_system_prompt(). So the system prompt had the identical gap: theoriginal framing here — that the prompt already consulted the client and only
the switch message disagreed — was wrong. It was a two-site problem.
The fix
LemonadeProvider.refresh_model_catalog()(providers/lemonade.py) reads thecatalog once.
_apply_local_switchcalls it immediately aftercreate_clientand before
_apply_switch, so the warm client reaches both the switchmessage and the prompt rebuild. The
callable()guard keeps the id-prefix ruleas the documented fallback for a client that has no such method.
Deliberately not calling
agent._inference_location()even though it wouldbe shorter: that method lives on
ChatAgent, and reaching for it here wouldcouple
gaia's stdio to thechatpackage's private surface and break every_ModelSwitchAgentfake in the suite.No doc surface asserts the old behaviour — the sentence is composed at runtime,
and
grepoverdocs/,hub/andsrc/finds it only ininference_location.pyand this test file.hub/agents/gaia/python/ships noREADME/SPEC/SKILL/CHANGELOG to keep in sync.