- Extract the two duplicated /model session-persist blocks into
_persist_model_switch_to_session; persist the route BOTH nested
(gateway_runtime, CLI reader) and top-level (TUI gateway's
_stored_session_runtime_overrides reader) so a CLI switch also
survives a desktop/TUI session.resume.
- Add SessionDB.session_gateway_runtime as the canonical tolerant
row-level route reader (session_yolo_enabled precedent); use it in
_restore_session_model instead of hand-rolled JSON parsing.
- Clear stale launch-time _explicit_api_key/_explicit_base_url when
resume restores a different provider (same leak guard
_apply_model_switch_result already has).
- 12 new tests incl. a real-SessionDB round trip; mutation-checked.
Two bugs caused resumed sessions to use the config default model instead
of the model the session was actually using:
1. CLI /model switch didn't persist the new model to the session DB row.
The gateway calls update_session_model() after a /model switch, but
the CLI path only updated in-memory state and the agent's runtime —
it never wrote the new model to the sessions.model column. So the DB
row always kept the original model from session creation.
2. Resume didn't restore model/provider from the session DB row.
_preload_resumed_session and _init_agent restored CWD and YOLO from
session_meta, but never read session_meta['model'] back into
self.model/self.provider. So even if the DB had the right model,
resume would use whatever was in config.yaml.
Fix:
- _handle_model_switch / _apply_model_switch_result: call
update_session_model() after a session-scoped /model switch (skipped
for --once and --global), mirroring the gateway's behavior.
- New _restore_session_model() method: restores model/provider from
session_meta on resume, with provider/base_url/api_mode from
model_config.gateway_runtime. Also swaps the running agent in-place
for mid-chat /resume.
- Call _restore_session_model() from all three resume paths:
_preload_resumed_session, _init_agent, and _handle_resume_command.
- Track _explicit_model_override flag so -m/--model on the CLI overrides
resume (user intent wins). Cleared on /new.
Follow-ups on salvaged #85287:
- discover_entrypoint_manifests() now carries BOTH the import-free kind
classification (from #85527) and capability declarations — the two
contracts compose in one function instead of the capability rewrite
dropping classification.
- Per-entry exception isolation: one malformed distribution no longer
blanks every other plugin's manifest (same contract as
providers/__init__.py entry-point scan).
- Documented the hermes_agent.plugin_capabilities group in the plugin
developer guide (pyproject example).
- Single-source the included note as _INCLUDED_NOTE and attach it at
BOTH status='included' sites (the zero-amount pricing-entry branch
previously returned the same status with no note).
- Docstring/comment precision on format_cost_label: the fallback
triggers on 4dp ROUNDING to 0.0000 (banker's rounding includes the
exact $0.00005 boundary), not truncation; note why the rendered-label
guard beats a naive Decimal threshold.
- Tests: replaced a dead assertion with the exact-boundary case
($0.00005), fixed an overclaiming comment, aligned the terminal
cost column.
- Insights formatters now route aggregate estimated cost through the
shared format_cost_label() instead of hardcoded 2dp — a sub-cent
aggregate (one cheap DeepSeek session, ~$0.0046) no longer renders
'Estimated: ~$0.00', the exact bug class this PR fixes (#79220).
- format_cost_label: positive amounts below $0.00005 render '~$<0.0001'
instead of the zero-looking '~$0.0000' 4dp truncation artifact.
- Renamed _format_cost_label -> format_cost_label (now a cross-module
shared helper).
- Tests: renamed test_gateway_format_hides_cost ->
test_gateway_format_hides_cache_details and
test_no_cost_section_when_all_zero ->
test_unknown_bucket_shown_for_costless_session (names contradicted
behavior); restored a real assertion in the custom-models test that
had been weakened to a comment; added sub-cent-aggregate and 4dp-floor
contract tests (mutation-checked).
Three cost-display honesty fixes:
1. Sub-cent cost label rendering (#79220) — _format_cost_label() scales
precision to magnitude: zero renders as '$0.00', sub-cent (< $0.01)
renders at 4 decimal places (e.g. '~$0.0046'), normal costs keep 2dp.
This fixes the bug where DeepSeek per-turn costs of $0.004640 rendered
as '~$0.00' despite amount_usd carrying full Decimal precision.
2. Cost bucket surfacing (#77223) — insights format_terminal and
format_gateway now display three cost buckets: estimated (with dollar
figure), included (session count, labeled 'subscription — no provider
invoice'), and unknown (session count, labeled 'no pricing data').
Previously, included and unknown sessions silently collapsed to $0 in
the aggregate view, hiding 315 of 473 sessions in the reporter's DB.
3. Subscription-included cost notes — estimate_usage_cost now attaches a
'subscription-included; no provider invoice for usage' note to
CostResult for subscription-included routes (openai-codex), so
consumers can distinguish 'free because subscription' from 'free
because $0 pricing'.
Fixes#79220Fixes#77223
- Deleted the id(cfg)-keyed _OVERRIDE_CACHE layer: id() is unique only
among live objects, so a config reload could serve stale overrides
forever when CPython reuses the freed dict's address. The upstream
load_config_readonly is already (mtime,size)-cached (~1 stat/hit), so
the local layer was redundant state with a correctness risk.
- _override_to_catalog_shape returns (patch, vision) instead of
smuggling an in-band _vision_override sentinel key through the merged
dict; removed the two dead call-site pops.
- _find_model_entry gains the :cloud/-cloud suffix fallback that
lookup_models_dev_context already had, so 'catalog hit' means the
same thing to every consumer — a suffix-keyed model (kimi-k2.6:cloud)
now counts as KNOWN and keeps its catalog capabilities instead of
being displaced by a fill-gap _default (mutation-checked contract
test added).
- get_model_info's unknown-model override path seeds the same safe
defaults as get_model_capabilities (200K ctx, tools on, 8192 out),
so a partial override no longer yields ctx=0/tools-off on that path
(contract test added); the DEFAULT_CONFIG defaults claim is now true
for both paths.
- Activated the previously-dead _MODELS_DEV_TO_PROVIDER reverse map
(lazily built, many-to-one aware) and used it in
_provider_override_section instead of a per-call linear scan.
Review follow-ups on the model_overrides feature:
- ONE canonical override schema everywhere. get_model_info previously
merged the override dict raw into the models.dev catalog shape
({**raw, **override}), so the documented context_window/supports_*
keys silently did nothing on that path (cost guard, inventory) while
working in capabilities/context paths — same config key, two
incompatible schemas. Overrides are now translated into the catalog
shape at the get_model_info boundary (_override_to_catalog_shape),
and sub-dicts (limit, modalities) are MERGED, not clobbered — an
override setting only context_window no longer wipes the catalog's
limit.output.
- _default is now a FILL-GAP default, not an override: it applies only
to models the catalog does not know (the #8731/#84482 self-unblock
path) and never displaces catalog data. A
_default: {context_window: 128000} can no longer clamp every model
of a provider. Explicit per-provider+model entries keep their
win-over-catalog semantics.
- Early-chain _override_context_window (model_metadata step 0b) is
explicit-only, so a _default can never preempt custom_providers
per-model settings or live probes; fill-gap defaults apply at the
lookup_models_dev_context catalog-miss boundary (step 5f) instead.
This fixes the precedence inversion where a provider/global _default
silently overrode an explicit per-endpoint per-model context_length.
- Provider keys accept BOTH id spaces (Hermes id and models.dev id:
copilot/github-copilot both work) and model ids match
case-insensitively, mirroring catalog lookup.
- Malformed override values (context_window: '512k') log a one-shot
warning instead of being silently swallowed.
- DEFAULT_CONFIG comment: removed the false family/dated-snapshot
inheritance claim, documented the recognized field list, fill-gap
semantics, and the id-space rule.
- Tests: rewritten for the new contracts (fill-gap invariants,
dual-id-space keys, sub-dict merge preservation, one-shot warning);
added a real-config-yaml e2e plumbing test (mutation-checked: fails
when the config key wiring is broken).
Add a unified model_overrides config section that lets users manually
declare context_window, max_output_tokens, capabilities, cost, and
family for any provider+model — winning over models.dev, OpenRouter, and
hardcoded defaults.
Resolution order (first hit wins):
1. model_overrides.<provider>.<model_id> (per-provider+model)
2. model_overrides.<provider>._default (per-provider default)
3. model_overrides._default (global default)
4. Normal catalog resolution
Key subtlety: an unknown model id (not in the
catalog) derives base metadata from sensible defaults before patching,
so overriding a model the catalog doesn't know yet is the supported
self-unblock path. This is exactly the #84482 scenario (Upstage
solar-pro4/syn-pro wrong context) and the #8731 scenario (custom/local
models with manual capability declaration).
Wired into:
- get_model_capabilities() — patches capability fields; unknown models
get safe defaults (tools on, vision/reasoning off) before patching
- lookup_models_dev_context() — context_window override, checked before
catalog lookup so it works even for providers not in PROVIDER_TO_MODELS_DEV
- get_model_info() — merges override dict onto catalog entry (shallow
merge); for unknown models, the override is the sole source of metadata
- get_model_context_length() — step 0b in the resolution pipeline,
before custom_providers (0c) and before any network probe
Config example:
model_overrides:
upstage:
solar-pro4:
context_window: 524288
syn-pro:
context_window: 65536
custom:my-local-vllm:
my-llava-model:
context_window: 8192
supports_vision: true
supports_reasoning: false
supports_tools: true
_default:
context_window: 128000
Fixes#8731Fixes#84482
Refs #47247
The set is no longer in parity with agent_init's fail-fast gate (which
still skips openrouter for a different reason: default route, not
unroutable). Say so instead of claiming parity.
BARE_BILLING_PROVIDERS incorrectly included "openrouter" alongside
"auto" and "custom". OpenRouter is a fully routable provider with
its own API key and base_url — sessions that used OpenRouter store
billing_provider="openrouter", and dropping it forces resume to the
current global model (e.g. a custom endpoint), which is the wrong
provider for the stored model.
Remove "openrouter" from the bare-bucket set so OpenRouter sessions
correctly restore their provider identity on resume.
Fixes#57588
cronjob(action=create) has supported a repeat cap since the tool
existed, but the ws handler dropped the param — UIs building on
cron.manage could not create run-N-times jobs. Forward it (digit
strings accepted, None keeps schedule-kind defaults). One-shot
relative schedules (bare 30m/2h) already flow through the schedule
string untouched.
Maintainer fixup on the #79787 salvage:
- An explicit fb.api_mode of "chat_completions" was silently overridden
by the codex_responses / bedrock re-detection pass (which only skipped
re-detection when the pre-computed mode was non-default). Track
explicitness in fb_api_mode_explicit and gate the whole re-detection
block on it.
- Replace the locals().get('fb_api_mode') dead-code hack with clean code
(fb_api_mode is always bound at that point).
- Restore the post-resolve /anthropic + api.anthropic.com host check for
named custom providers whose base_url comes from config rather than
the fallback entry (#32243, #49247), which the PR's restructure dropped.
- Add regression tests: explicit api_mode honored (incl. explicit
chat_completions not overridden), /anthropic-hint fallback detected
pre-rewrite, api_mode forwarded to resolve_provider_client, plain
fallback unchanged.
Fallback activation determined api_mode from the POST-rewrite client
base_url, losing the Anthropic wire signal for /anthropic endpoints
routed through provider 'custom', and never honored an explicit
fb.api_mode config field. Pre-compute fb_api_mode from the ORIGINAL
fallback base_url hint (before _to_openai_base_url rewriting), honor
the explicit api_mode config field, check provider name before the
base_url gate, and pass api_mode into resolve_provider_client at the
fallback call site.
Salvaged from PR #79787 (chat_completion_helpers.py hunks; the
auxiliary_client.py hunk is redundant with #85466's wrap_base fix).
- /model switch now refreshes agent._custom_providers from the config
loaded during the switch before re-evaluating cache policy — a
prompt_caching flag added to config.yaml after session start was
invisible to a mid-session switch (policy read the stale init-time
snapshot while context_length resolution used the live list).
- Production-path test: real config.yaml in the modern providers: dict
shape through the real loader chain, exercising the init-order fallback
(no _custom_providers attr) for both the fable opt-in and the opus
explicit opt-out.
- Pin operator kill-switch precedence: _cache_disabled (prompt_caching.
cache_ttl falsy) beats an explicit per-model prompt_caching: true.
- Log (debug) instead of silently swallowing capability-lookup failures in
anthropic_prompt_cache_policy — a swallowed failure would otherwise
downgrade an explicit prompt_caching: true to (False, False) with zero
trace. Matches the sibling MoA branch's logger.debug style.
- Use load_config_readonly() for the None-fallback in
get_custom_provider_model_capability: the helper only reads, and the
fallback fires on the blank-stub paths (agent init before
_custom_providers is assigned, MoA/auxiliary destination planning), so
skip the ~135us defensive deepcopy per call.
- Add route-isolation regression tests at both levels (config helper +
agent policy): a prompt_caching declaration for one provider route must
never apply to another route with the same model name. Mutation-checked:
both tests fail when the URL match is disabled.
Once you have toggled a few items on and off there is no way to get
back to the shipped layout short of remembering which ids are in
STATUSBAR_HIDDEN_BY_DEFAULT. Add a row to the bar's right-click menu
that restores that set.
The row is disabled rather than hidden when nothing is customized, so
it also advertises that a shipped layout exists. Reset touches item
layout only — whole-bar visibility is a separate preference, and
resetting from the bar's own menu should not make the bar you are
right-clicking disappear.
Now that subscriptions survive `done` (completion is reversible —
on every 5s notifier tick forever. Add
kanban_db.purge_stale_done_notify_subs(): one DELETE removing subs
whose task has been done with no new events past a retention window
(age = latest task event, falling back to completed_at/created_at, so
any activity exempts the task; a reopened task is exempt by status
alone). The notifier watcher runs it per board once at startup and at
most hourly, re-reading kanban.done_sub_retention_days (config.yaml,
default 30; 0 disables) at each sweep.
cronjob(action=list) defaults to include_disabled=False, so pausing a
job makes it vanish from any ws-driven UI's next refresh — an
enable/disable toggle reads as silent deletion (reported against the
Hermes-Bot-Mode routines pane). Forward the flag from params.
Carry the worker's completion handoff into the synthetic creator wake
turn and label it as an automatic notification with inspect-the-board /
don't-recreate guidance, so a woken orchestrator doesn't re-decompose
work that already exists (#70752).
Salvaged from PR #71100 by @yinkev; ported onto the restructured wake
region (delivery_mode gating, scope_id, sub chat_id destinations). The
auto_subscribe_on_create config-default half of the original PR was
dropped as already superseded on main.
Widen #62804's class fix: _get_goal_manager_for_event and
_get_heartbeat_manager_for_event also call get_or_create_session on
behalf of the triggering event; when that event is internal the lookup
must not advance the user-activity clock either.
For a push-adapter subscription with delivery_mode='wake' the visible text
ping is intentionally skipped (the send_passive gate), so the wake injection
IS the sole delivery — yet the event cursor advanced BEFORE the wake, which
then ran best-effort with its failure swallowed. A single failed wake
permanently lost the event.
Apply the same ordering the non-push (api_server) self-post branch already
uses: attempt the wake BEFORE advancing the cursor; on failure rewind the
claim (_kanban_rewind) and bump the per-sub failure counter so the next tick
retries; on success reset the counter; drop the subscription after
MAX_SEND_FAILURES consecutive failures like text sends do. notify+wake mode
is unchanged: the text ping is the delivery and the wake stays best-effort
after the cursor advance.
Extracts the residual delivery-ordering insight from closed PR #84191.
Co-authored-by: MaximCrabbe <crabbemaxim@gmail.com>
The whole-bar visibility atom defaulted to false (opt-in). Flip it to
true so the bar shows on first launch. The context-usage meter and
other diagnostic items remain hidden via STATUSBAR_HIDDEN_BY_DEFAULT,
so only the core status items (gateway health, model pill, command
center) appear out of the box. The toggle keybind and ⌘K row still
let users hide it.
The cherry-picked tests predate #85508's honest fallback-chain phrasing
and each other: assertions pinned the old 'exhausted or unavailable'
literal and #83188's no_agent fallback-note behavior, which #77648's
mode gate supersedes (no provider classification at all for no_agent
jobs). Assert the composed contract instead.
`_summarize_cron_failure_for_delivery` classifies a failed job by
substring-matching the error prose — "timed out", "429",
`authenticat|authoriz` — and maps any hit onto a provider-shaped
explanation, without consulting the job's execution mode.
A `no_agent` job IS its script: `run_job` short-circuits it before any
model is reached. Provider timeouts, rate limits, auth errors and
fallback chains are therefore structurally impossible for it, yet those
branches are tested first.
`_run_job_script` reports a timeout as "Script timed out after {n}s:
{path}". That contains "timed out", so a shell script exceeding its
timeout is delivered to chat as:
⚠️ Cron 'x' failed: provider timeout. Fallback chain was exhausted
or unavailable.
for a job that never opened a socket, sending the reader to inspect
model routing while the actual fault is a shell script. "429" or
"authentication" appearing anywhere in a script's output misfires the
same way.
Gate the three provider branches on `not job.get("no_agent")` and let
script jobs fall through to the existing generic cleaner, which already
reports the real error and names the script. No new message text.
The auth branch carries a word-boundary guard so "oauth" and "4015" do
not trip it, which addresses one substring false-positive; gating on
mode removes the remaining class for script jobs.
Tests: the summarizer had no direct coverage — the only test referencing
it patches it out and asserts on its arguments. Adds parametrized cases
pinning both directions: script jobs are never blamed on a provider
(including when their output contains "429" or "authentication"), and
agent-mode jobs keep the existing provider summaries unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sibling test pinned the old zero-arg constructor; salvaged #80493 gave
_ProviderCollector a required provider name (used for skill registration
and PluginContext delegation).
Builds on the three salvaged commits: adds the sources and integration points
they leave out, so a pip-installed memory provider is not a second-class
citizen next to a directory install.
Discovery
- Project-local providers (./.hermes/plugins/<name>/), gated on
HERMES_ENABLE_PROJECT_PLUGINS exactly as PluginManager gates its own project
scan. Completes the four sources CONTRIBUTING.md and AGENTS.md already
promised; memory was the only discovery system missing two of them.
- find_provider_dir() now resolves a package entry point to its directory.
This is load-bearing: config_schema.py (the dashboard panel) and cli.py (the
`hermes <provider>` subcommands) are read from disk rather than imported, so
without a directory a pip-installed provider silently lost both.
- list_memory_provider_names() includes entry-point providers, so they appear
in the dashboard's memory.provider dropdown.
Resolution stays import-free. hermes_cli.plugins.resolve_module_origin() is
extracted from _resolve_module_source() (added by the salvaged #76567) and
shared, so discovery walks a module's file layout instead of importing it.
find_provider_dir() is called from the dashboard and from argparse setup, long
before the operator has chosen a provider — importing every installed candidate
would execute third-party code on the strength of a package being present.
A test asserts the resolution leaves no side effects and no sys.modules entry.
Registration
- PluginContext gains register_memory_provider(). Memory was the only provider
category without one; context engine, image gen, video gen, web search,
browser, TTS, transcription, secret source, dashboard auth and platform all
have one.
- _ProviderCollector delegates unknown register_* calls to a real
PluginContext instead of carrying three hand-written no-ops. It silently
dropped register_tool/register_hook, and had no register_auxiliary_task at
all — despite PluginContext.register_auxiliary_task documenting a memory
provider (hindsight's pre-retain dedup) as its worked example. It can no
longer drift behind PluginContext.
- A raise after register_memory_provider() no longer costs the provider. The
loader caught it into a debug log, discarded the registered instance, and
fell through to "instantiate any MemoryProvider subclass" — returning a
different, unconfigured provider. A silent downgrade that looked like
success, and the exact outcome of calling register_auxiliary_task.
Activation is unchanged: still gated on memory.provider naming the plugin, and
covered by a test so the real PluginContext cannot start requiring
plugins.enabled — that would break every existing user-installed provider.
Verified end to end against a real third-party provider (kainappsinc/elephant)
installed by pip alone, with no directory copy: it appears in the dropdown,
resolves its directory, loads with its tools, and renders its dashboard panel.
Closes#40101.
Documents and tests the routing contract the sweeper review asked about:
classification records the manifest but does not activate anything.
- model-provider test now exercises providers.get_provider_profile() against
the pip-only name (None today — providers discovery is directory-based)
and asserts the module never leaks into sys.modules via that path.
- new test for the mnemosyne shape: a pip entry point duplicating a
same-name directory provider. The pip copy is classified exclusive and
never imported; the directory copy still activates through
plugins.memory discovery, exactly once.
- _classify_entrypoint_kind docstring now states the activation contract
explicitly: pip-only providers were equally unactivatable pre-change
(both destination systems are directory-only; the
hermes_agent.memory_providers entry-point group has no consumers), so
classification only removes the wasted import. Entry-point activation
is tracked upstream (#40644 for memory); this change is its
prerequisite, preventing double import once it lands.