feat(gateway): bind toolsets per session, and give spawn --toolsets back - #317
Merged
OmarB97 merged 1 commit intoAug 2, 2026
Merged
Conversation
… back #315 removed `hermes desktop spawn --toolsets` because it had nowhere to land: `session.create` took no toolsets parameter, so the value reached the renderer and died there. This adds the missing half — a per-session toolset pin on the gateway — and restores the flag on top of it. `toolsets` joins model / reasoning_effort / fast as a `session.create` parameter, and follows their posture exactly: it steers ONE chat and never writes config. That distinction is the point. `tools.configure` is the global writer (it calls save_config), so it cannot express "run this one chat with just file+terminal" without changing what the user's next chat gets. Binding at create time is also what keeps it cheap. AGENTS.md rules out swapping toolsets mid-conversation because it invalidates the per-conversation prompt cache; this resolves before the agent is built and before the first turn, so there is no cached prefix to invalidate. Omitted is the unchanged path every typed chat takes. Two things this had to get right: - **None is a value, not an absence.** `all`/`*` resolves to `enabled_toolsets=None`, which is also what "no pin given" would naturally be. Conflating them would silently downgrade `--toolsets all` to the process default. `_NO_TOOLSET_OVERRIDE` is the sentinel that keeps them apart, and the deferred build threads the pin on key PRESENCE rather than truthiness. - **A pin that cannot be honored fails the create.** Names are validated against the same vocabulary `HERMES_TUI_TOOLSETS` accepts — built-ins, plugin toolsets after discovery, enabled MCP servers, `all`/`*` — and an unresolvable name is refused (4026) rather than dropped. A session whose tools are not the ones asked for is the exact failure the pin exists to prevent, and it is how NousResearch#38798 became a text-only agent nobody could explain. Validation runs BEFORE the active-session slot is claimed, so a typo cannot burn a lease that only session.close releases. The resolver is extracted rather than duplicated: `_resolve_toolset_names` now backs both `HERMES_TUI_TOOLSETS` and the session pin, so the two cannot drift into subtly different ideas of what a toolset name is. The extraction is behavior-preserving — the env resolver keeps its exact stderr warnings and fallbacks, covered by its existing 11 tests. The pin dies at a `/new` boundary alongside the other session-scoped overrides. That is not just symmetry: `tools.configure` rebuilds through `_reset_session_agent`, so toggling a toolset in the UI re-derives from config instead of being silently overruled by a spawn's pin the user can neither see nor clear. Renderer side, the break #315 documented is closed: `useSpawnBridge` reads `payload.toolsets` into `SessionCreateOverrides`, and `desktopSessionCreateParams` puts it on the wire. The submit gate now calls `hasSessionOverrides` instead of a hand-rolled `model || provider || profile` list — that hand-rolled list is precisely what made a toolsets-only spawn send no overrides at all, and it would have silently swallowed the next field too. Tests cover every leg end to end: CLI splitting and its non-empty check, `parseSpawnRequest` shape, the bridge (including a toolsets-only spawn), `session.create` params, gateway validation/pinning/`all`/lease ordering, `_make_agent` forwarding, and the `/new` clear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Follow-up to #315. That PR removed
hermes desktop spawn --toolsetsbecause the flag had nowhere to land:session.createtook no toolsets parameter, so the value travelled CLI → HTTP → renderer and died there. This adds the missing half — a per-session toolset pin on the gateway — and restores the flag on top of it.toolsetsjoinsmodel/reasoning_effort/fastas asession.createparameter, with the same posture: it steers one chat and never writes config. That distinction is the whole point.tools.configureis the global writer (it callssave_config), so it cannot express "run this chat with just file+terminal" without changing what the user's next chat gets.Binding at create time is also what keeps it cheap. AGENTS.md rules out swapping toolsets mid-conversation because it invalidates the per-conversation prompt cache — this resolves before the agent is built and before the first turn, so there is no cached prefix to invalidate. Omitted is the unchanged path every typed chat takes.
Two things this had to get right
Noneis a value, not an absence.all/*resolves toenabled_toolsets=None— andNoneis also what "no pin given" would naturally be. Conflating them would silently downgrade--toolsets allto the process default._NO_TOOLSET_OVERRIDEis the sentinel that keeps them apart, and the deferred build threads the pin on key presence rather than truthiness.A pin that cannot be honored fails the create. Names are validated against the same vocabulary
HERMES_TUI_TOOLSETSaccepts — built-ins, plugin toolsets (after a discovery pass), enabled MCP servers, andall/*— and an unresolvable name is refused (4026) rather than dropped. Handing back a session whose tools are not the ones asked for is exactly the failure the pin exists to prevent; it is also how NousResearch#38798 became a text-only agent nobody could explain. Validation runs before the active-session slot is claimed, so a typo cannot burn a lease that onlysession.closereleases.Notes for review
_resolve_toolset_namesnow backs bothHERMES_TUI_TOOLSETSand the session pin, so the two cannot drift into subtly different ideas of what a toolset name is. The extraction is behavior-preserving — the env resolver keeps its exact stderr warnings and fallback ordering, and its existing 11 tests pass untouched./newboundary alongside the other session-scoped overrides. Not just symmetry:tools.configurerebuilds through_reset_session_agent, so toggling a toolset in the UI re-derives from config instead of being silently overruled by a spawn's pin the user can neither see nor clear.hasSessionOverridesinstead of a hand-rolledmodel || provider || profilelist. That hand-rolled list is precisely why a toolsets-only spawn would have sent no overrides at all — and it would have silently swallowed the next field someone adds too.Related Issue
Follow-up to #315 (which removed the dead flag) and #298 (which introduced it).
Type of Change
Changes Made
Gateway (the missing half)
tui_gateway/server.py_resolve_toolset_names+_ToolsetResolution: the shared resolver, extracted from_load_enabled_toolsets(which now calls it and keeps its warnings verbatim).session.createacceptstoolsets, validates it, and pinscreate_toolsets_override— before claiming the session slot._make_agentgrowstoolsets_override(sentinel-defaulted) and forwards it asenabled_toolsets._start_agent_buildthreads the pin to the deferred build;_reset_session_agentclears it.Client chain (the leg that was broken)
use-spawn-bridge.ts— readspayload.toolsetsinto the overrides; gate switched tohasSessionOverrides.session-overrides.ts—SessionCreateOverrides.toolsets;hasSessionOverridescounts it (an empty array is not a pin).use-session-actions/index.ts—desktopSessionCreateParamsputs it on the wire, only when pinned.spawn-control.ts,global.d.ts— carry the field again; an all-blank list is refused here rather than becoming a silent "inherit".hermes_cli/subcommands/gui.py,hermes_cli/desktop_spawn.py—--toolsetsrestored, split onto the wire, with a non-empty check argparse cannot express.Tests — one per leg: CLI splitting + non-empty check,
parseSpawnRequestshape, the bridge (including a toolsets-only spawn),session.createparams, gateway validation/pinning/all/lease-ordering,_make_agentforwarding, and the/newclear.How to Test
Verified end to end against a live desktop app in an isolated
HERMES_HOME, driven by the real CLI over the real control channel (modelqwen3.6-27b-nvfp4). Each chat was asked to name every tool it could call:--toolsets filepatch, read_file, search_files, write_fileclarify, close_terminal, delegate_task, execute_code, patch, process, project_create, project_list, project_switch, read_file, read_terminal, search_files, session_search, skill_manage, skill_view, skills_list, terminal, web_extract, web_search, write_fileThe pinned chat got exactly the
filetoolset and nothing else — no terminal, no web, no delegation — while the control in the same app was unaffected. The ~10.7k-token difference is the tool schemas the pin removed.The negative case is the sharpest proof that the value reaches
session.createrather than dying in the renderer as it used to:Delivered to the renderer, and no session was created. Before this PR an unknown name was simply ignored and the chat started with the app's own toolsets. A valid pin in the same app created its session normally, so the toolset name is the only variable.
Reproduce:
hermes desktop spawn "list this repo" --toolsets file,terminal— the new chat has exactly those tools; other chats are unaffected andconfig.yamlis untouched.--toolsets file,notarealtoolset— refused bysession.create(4026, naming the bad entry), surfaced as a toast via the existingnotifyErrorpath inuse-prompt-actions/submit.ts.--toolsets allmeans all, not "the process default".Suites (macOS 15 / Darwin 25.6.0, on
8bfff38):→ 920 tests passed, 0 failed across 42 files.
→ 82 passed.
npm run typecheckand eslint on the touched files are clean.Note for anyone re-running the Python side in a single pytest process:
tests/tui_gateway/andtests/test_tui_gateway_server.pyshare module state and produce ~32 failures when run together in one process. That is pre-existing (it reproduces onmainwith these files untouched) and is whyscripts/run_tests.shisolates each file in its own subprocess.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the full gateway + spawn surface (920 passed) plus the desktop vitest/typecheck/lint, not the whole suiteDocumentation & Housekeeping
docs/, docstrings) — docstrings +--toolsetshelp text; no doc referenced this flagcli-config.yaml.exampleif I added/changed config keys — N/A (deliberately not a config key)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs
Live run — same app, same channel, only the flag differs:
Test suites: