Skip to content

fix(gateway): build the composer's model picker under the target profile - #329

Merged
OmarB97 merged 1 commit into
mainfrom
fix/composer-provider-target-profile-fork-2026-08-02
Aug 2, 2026
Merged

fix(gateway): build the composer's model picker under the target profile#329
OmarB97 merged 1 commit into
mainfrom
fix/composer-provider-target-profile-fork-2026-08-02

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

What's wrong

Sibling of #325, same root cause on the other half of the composer.

Provider names are per-profile vocabularyproviders: / custom_providers: live in each profile's own config.yaml. Three RPC handlers resolved them on the RPC thread with no HERMES_HOME bound, so all three answered for the launch profile.

Measured on two real temp profiles (launcher and worker, each with its own provider), driving the real RPCs:

model.options for a WORKER session
  → provider 'launch-router', model 'launcher/model-A'
  → provider list contained launch-router, NOT worker-local

config.set key=model applying worker's OWN provider to a WORKER session
  → error: "Unknown provider 'worker-local'"

So the picker could only offer names the session's profile does not define, and refused the one name it does.

Picking from that list is how a session ends up stamped with a provider its own profile cannot resolve. The turn then dies in agent init on "Unknown provider", leaving the 0-message husk #321 fixed for hermes desktop spawn --provider. This is the desktop composer's path to the same dead end — and the mechanism behind the foreign ai-router provider on the meshboard-game-dev rows in #325.

The fix

Three sites, one binding each (_profile_home_bound, added in #325):

Handler Why it needs the session's profile
model.options Build the picker under the profile it is for.
config.set key=model Apply the pick under the same profile the picker offered it from. _apply_model_switch reads providers: via load_config() and asks resolve_runtime_provider to name the target — all HERMES_HOME reads.
model.save_key The picker's "connect" action. .env is per-profile, so the key must land in the profile whose providers are shown.

config.set's model branch moves to _config_set_model unchanged so the caller can wrap it. switch_model persists through save_config(), which resolves the same home — so a --global write from a foreign-profile session now lands in that profile's config.yaml rather than the launcher's.

model.save_key's session lookup moves above the credential write to make the binding possible. Without this, the model.options fix alone would leave a just-connected row still showing unauthenticated — the write and the refresh would target different profiles.

model.disconnect is deliberately untouched: it takes no session and is a global provider disconnect, so there is nothing to scope it by.

The no-session picker

A live session is resolved by its own home — that is where its turn will run.

For the picker's other opening — a chat that does not exist yet — there is no session to ask. model.options and model.save_key now also accept profile.

That is not a new policy. The REST twin /api/model/options has always taken it and documents it:

profile scopes the picker context (current model/provider, custom providers from config, per-profile .env auth state)

So this closes a divergence between two surfaces whose response shapes are otherwise 1:1. The desktop sends it via a new apiRequestProfile() accessor over the same _apiProfile its REST calls already scope with — fixing the picker opened before a chat exists, which is the one path that reaches the gateway with no session id (model-menu-panel is gateway-first even with no session, per NousResearch#53817).

Session first, profile second, so an existing session can never be mis-scoped by a stale client hint.

Verification

Real temp profiles, no mocks (tests/test_profile_isolation_runtime.py), plus desktop unit tests.

4 of the 7 new Python tests fail on the parent commit. The others are deliberate controls:

  • a launch-profile session must still take the unbound path — binding the target profile must not mean "always foreign";
  • a provider only the LAUNCH profile defines must still be rejected. This one is worth reading twice: before this change it was accepted onto a worker session. That is the exact mechanism that stamps a foreign provider onto a session row, and it now fails at the switch — reported to the client — instead of silently at turn start.

Suites: tests/test_tui_gateway_server.py + tests/tui_gateway/ + tests/test_profile_isolation_runtime.py866 passed, 4 failed, the same 4 this machine produces on clean main (host-config-dependent, baselined in the #325 session). tests/hermes_cli/ model-switch, inventory, runtime-provider, config and profile-unification suites → 506 passed. Desktop src/lib → 364 passed across 46 files.

One desktop markdown-fuzz test timed out on the first run at load average 94 (other agents' pytest runs saturating the box) and passes clean once the machine settled — not related to this change, which touches no markdown code.

Risk

Low. A session with no profile_home and a client that sends no profile resolves exactly as before — _profile_home_bound(None) is a no-op and _profile_home(None) returns None. The behavior change is confined to sessions that already carry a foreign profile home, plus the newly-scoped no-session picker, where the previous answer was wrong.

The one deliberate behavior change beyond that: a key saved from a foreign-profile session's picker now lands in that profile's .env instead of the launcher's. That is required for the picker to be self-consistent, and matches what /api/model/set + /api/model/options already do on the REST side.

Sibling of #325, same root cause on the other half of the composer. Provider
names are per-profile vocabulary — `providers:` / `custom_providers:` live in
each profile's own config.yaml — and three RPC handlers resolved them on the
RPC thread with no HERMES_HOME bound, so they all answered for the LAUNCH
profile.

Measured on two real temp profiles ("launcher" and "worker", each with its own
provider), driving the real RPCs:

  model.options for a WORKER session
    → provider 'launch-router', model 'launcher/model-A', and the provider
      list contained launch-router but not worker-local

  config.set key=model applying worker's OWN provider to a WORKER session
    → error: "Unknown provider 'worker-local'"

So the picker could only offer names the session's profile does not define,
and refused the one name it does. Picking from that list is how a session ends
up stamped with a provider its own profile cannot resolve — the turn then dies
in agent init on "Unknown provider", leaving the 0-message husk #321 fixed for
`hermes desktop spawn --provider`. This is the desktop composer's path to the
same dead end.

Three sites, one binding each:

* `model.options` — build the picker under the profile it is FOR.
* `config.set key=model` — apply the pick under the same profile the picker
  offered it from. `_apply_model_switch` reads `providers:` through
  `load_config()` and asks `resolve_runtime_provider` to name the target, all
  HERMES_HOME reads. Its body moves to `_config_set_model` unchanged so the
  caller can wrap it; `switch_model` persists through `save_config()`, which
  resolves the same home, so a `--global` write from a foreign-profile session
  now lands in that profile's config.yaml rather than the launcher's.
* `model.save_key` — the picker's "connect" action. `.env` is per-profile, so
  the key has to land in the profile whose providers are being shown; the
  session lookup moves above the write to make that possible. Without this the
  model.options fix would leave a just-connected row still unauthenticated.

`model.disconnect` is deliberately untouched: it takes no session and is a
global provider disconnect, so there is nothing to scope it by.

A live session is resolved by its OWN home — that is where its turn will run.
For the picker's other opening, a chat that does not exist yet, there is no
session to ask, so `model.options` and `model.save_key` now also accept
`profile`. That is not a new policy: the REST twin `/api/model/options` has
always taken it and documents it ("``profile`` scopes the picker context"), so
this closes a divergence between two surfaces whose response shapes are
otherwise 1:1. The desktop sends it via a new `apiRequestProfile()` accessor
over the same `_apiProfile` its REST calls already scope with — which is what
fixes the picker opened before a chat exists, the one path that reaches the
gateway with no session id.

Session first, `profile` second, so an existing session can never be
mis-scoped by a stale client hint.

Verified against real temp profiles in tests/test_profile_isolation_runtime.py
(no mocks) plus the desktop unit tests. Four of the seven new Python tests fail
on the parent commit; the rest are controls — a launch-profile session must
still take the unbound path, and a provider only the LAUNCH profile defines
must still be rejected (it was ACCEPTED before this, which is the exact
mechanism that stamped a foreign provider onto a session row).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@OmarB97
OmarB97 merged commit 4f3ae1d into main Aug 2, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant