The own-bundle exemption compared name == hermes-<platform> for any key, so a
misspelt platform key naming its own bundle (telegarm: [hermes-telegarm]) went
silent in both the session-time warning and `hermes config check`, where main
reported it. is_known_platform_bundle() exempts the bundle only for a builtin,
shipped plugins/platforms, left-core or registered platform.
cli-config.yaml.example seeded `teams: [hermes-teams]` and
`google_chat: [hermes-google_chat]` into every install (install.sh,
install.ps1, the Docker stage2 hook, `hermes doctor --fix`). Both are
bundled plugin platforms with no static toolset, so every schema build
(the kanban gate resolves every saved row) logged "platform 'teams' has
no valid toolsets configured" to errors.log on homes that never set up
Teams or Google Chat, and still did once Teams was enabled.
- Drop the two rows from the example: a plugin platform falls back to
hermes-<platform> on its own (resolution identical with and without
the row, before and after plugin discovery).
- The runtime warning and `hermes config check` count a platform's own
hermes-<platform> bundle as valid, so existing homes (incl. the
homeassistant row pre-left-core homes carry) stay quiet; a typo in the
row still warns. The own-bundle check runs before the resolver, so the
hot path does no manifest scan, and the catalog scan from the salvaged
commit is dropped (862 catalog YAMLs parsed per call).
Found on Hermes Cloud staging.
mcp-<server> is the registered toolset name and the bare server name its
alias, not the other way round. The stale-toolset test now stubs one
portable MCP server so the prefixed-name rule for plugin-provided servers
is covered too (removing it turns the test red).
MCP tools register under the toolset name mcp-<server>, an alias that only
exists after MCP discovery. `hermes config migrate`, `hermes update` and
`hermes config check` run without discovery, so a platform_toolsets entry
such as mcp-codegraph for a configured server was reported as an unknown
toolset with a 'hermes-cli' hint (#78102).
saved_toolset_resolver now accepts the prefixed form of every configured
and plugin-provided (portable) MCP server. An mcp-<name> with no such
server is still reported.
Rule from #106715 by @a692570, adapted onto the shared resolver that
landed in #127782.
saved_toolset_resolver built the hermes-<platform> bundle set eagerly, so
every migrate_config run (hermes update, config migrate, doctor) and every
config check paid a full bundled+user manifest scan even with no saved
platform_toolsets. The scan is now cached and lazy, like the plugin lookup.
The stale-toolset test stubs the plugin lookups so host-installed plugins
cannot change its verdict.
Follow-up to the config check diagnostics from #127392.
- Saved platform_toolsets are validated by one resolver,
toolset_validation.saved_toolset_resolver, shared with
`hermes config migrate`. The two commands previously disagreed: migrate
flagged MCP server names and plugin-platform bundles (even suggesting
'hermes-teams' for 'hermes-teams'), while config check accepted them.
- A configured MCP server is known even when disabled (enabled: false);
it is inactive, not a typo.
- known_plugin_toolsets is no longer treated as proof that a toolset
exists. It records what a platform was offered at save time, so it hid
toolsets of uninstalled plugins, the stale case this check is for.
- Plugin-provided names are consulted only for names nothing else
explains, so a clean config no longer runs plugin discovery.
- A malformed mcp_servers (a list) no longer crashes the command.
- The top-level `toolsets` check is dropped: the CLI resolves its
toolsets from --toolsets or platform_toolsets, and reads the top-level
key only for the legacy kanban opt-in.
- Disabled platforms are read with the runtime's plugins.disabled reader,
so a non-list value (which the runtime ignores) is no longer reported
as disabling a platform. Required credentials come from
_platform_manifest_env_entries, which now takes optional=False.
- Tests use a synthetic platform manifest instead of the real Telegram
one, and assert config check and migrate report the same names.
#116691 taught the runtime (`_get_platform_tools`) to read the
`'["browser", "terminal"]'` string an older `hermes config set` stored as
the user's real toolset selection, but the two other readers of the section
kept the old `isinstance(raw, list)` gate:
- `validate_platform_toolsets` (hermes doctor / startup warnings) reported
"invalid toolset value ... falling back to 'hermes-cli'" for a value the
runtime resolves to browser+terminal — a false warning.
- `plugins_cmd._toggle_plugin_toolset` skipped the string entry, so
`hermes plugins enable <p>` never reached that platform.
- `nous_subscription._toolset_enabled` treated it as an empty list.
Move the list-literal parse into one function,
`toolset_validation.parse_platform_toolsets_value`, and route all four
readers through it; the plugin toggle re-saves the entry as a real list so
the string never persists past the next write. `_coerce_platform_toolsets_value`
keeps its once-per-platform warning for values that do not parse.
Why one parser: three readers with three notions of "configured" is exactly
how #115866 went unnoticed. Follow-up to #116691 (independent review finding).
validate_platform_toolsets() accumulated a single valid_count across every
platform, so the "zero valid toolsets" safety net was suppressed as soon as any
one platform carried a valid toolset. A platform wiped to [] — the active one,
typically cli — therefore produced no warning at all.
resolve_enabled_toolsets() honours that empty list verbatim ([] is a list, so
the platform-default fallback is skipped), leaving the agent with zero tool
schemas. The model then has nothing to call and emits the tool call as
assistant text with finish_reason=stop: no error, no warning, no log entry.
That is the silent-failure mode this module was written to prevent (#38798).
Note the asymmetry this leaves intact: a malformed *string* value is not a list,
so it falls back to the platform default and fails open (#78103); an empty list
fails closed. The fail-closed resolution is deliberate (the explicit_empty_
selection contract in tools_config.py, and #82010 wants it persistable), so this
only adds the missing warning and does not change resolution semantics.
Fixes#89050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A config migration (or hand-edit) that leaves an invalid toolset name in
`platform_toolsets` — e.g. the #38798 corruption that rewrote `hermes-cli` to
the non-existent `hermes` — silently disabled all affected tools:
resolve_toolset() returns [] for an unknown name, so the agent quietly lost its
tools with no error, warning, or log entry and degraded to text-only replies.
Surface it loudly at two points:
- After migration (migrate_config): validate platform_toolsets and record/print
a warning per unknown name, with a `hermes-<platform>` suggestion when that
would have been valid (the exact #38798 shape).
- At runtime (_get_platform_tools): if a platform was explicitly configured but
every toolset name is invalid, log a warning when tools are resolved for a
session — so an ALREADY-corrupted config is caught at startup, not only on the
next `hermes update`.
Logic lives in a new pure, side-effect-free helper (toolset_validation.py) with
validate_toolset injected, so it is unit-testable without the tool registry.
Note: the original v25→v26 migration that caused the corruption no longer
exists (config format is now v30; no migration step rewrites toolset names).
This change is the durable defense against the silent-failure mode regardless
of cause, matching the issue's "Expected: log a warning".
Salvaged from #39207 by @lEWFkRAD (authorship preserved via cherry-pick).
Tests: 9 helper cases (incl. the #38798 corruption shape, mixed valid/invalid,
zero-tools state, non-dict/scalar/non-string) + a runtime caplog test — both the
helper warning and the runtime guard mutation-verified to fail without the fix.
Closes#38798. Supersedes #39581 (prevent-in-v25→v26 — that path is gone),
#41006 / #40208 (repair-migration for already-corrupted configs).