mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The Ink TUI, Desktop and the gateway-attached CLI send /model as a session.mutate model mutation straight to the session owner, so the earlier handlers never saw it and `reset` was frozen into the policy as a model name (the provider then got `model: reset`). prepare_model now treats `reset` as a pick of the owning profile's configured model, read fresh from config.yaml (session-scoped, never written back, not held behind the cost ask). A session already on that default gets a `noop` with an "already on the configured default" note and nothing is committed. `reset` with any other selection field is refused like on every other surface. A messaging chat viewed through the owner clears its override through the gateway's own reset; a reset queued behind a running turn shows the default it will adopt. The TUI, Desktop and the attached CLI print the owner's note.
379 lines
23 KiB
Python
379 lines
23 KiB
Python
"""Resolve explicit model changes using the existing source model switch pipeline."""
|
|
import asyncio
|
|
import json
|
|
from dataclasses import asdict, replace
|
|
from typing import NamedTuple
|
|
|
|
from gateway.session_route_identity import ROUTE_BOUND_KEYS
|
|
from hermes_state_runtime import RuntimeStoreError
|
|
|
|
|
|
class _Route(NamedTuple):
|
|
provider: str
|
|
model: str
|
|
base_url: str
|
|
api_key: object # a callable bearer (Azure Entra) stays callable
|
|
|
|
|
|
def _current_route(authority, live, old):
|
|
"""``(config, route)`` the session runs now, both from ONE read of the owning profile.
|
|
|
|
A following session runs the profile's current route, so its frozen creation endpoint beside
|
|
the current key would send that key to a host the profile left. Main switched from the live
|
|
runtime's whole tuple (``tui_gateway.model_switch._current_model_runtime``). The provider is
|
|
the configured name: a named ``providers:`` entry resolves to the bare ``custom`` class, and a
|
|
pick on the same entry must not read as a route change. Only the session's own route counts:
|
|
a fallback is where a turn lands, not what the user switches from. A route that cannot
|
|
authenticate here must not block leaving it: like main's agent-less ``--provider`` path, the
|
|
pick then switches from the configured provider alone and resolves its own target.
|
|
"""
|
|
from gateway.session_policy import profile_read_scope
|
|
from hermes_cli.auth import AuthError
|
|
with profile_read_scope(old, authority):
|
|
config = old.config(authority)
|
|
block = config.get('model') or {}
|
|
try:
|
|
model, runtime = authority.runner._resolve_session_agent_runtime(
|
|
source=live.source, session_key=live.route, fallback=False)
|
|
except AuthError:
|
|
return config, _Route(block.get('provider') or '', old.current_model(authority) or '',
|
|
block.get('base_url') or '', '')
|
|
return config, _Route(block.get('provider') or runtime.get('provider') or '', model or '',
|
|
runtime.get('base_url') or '', runtime.get('api_key') or '')
|
|
|
|
|
|
async def prepare_model(authority, live, payload, prepared):
|
|
from gateway.session_policy import restore_policy, bind_launch_key, bound_route_holds, route_launch_key
|
|
from gateway.session_policy_credentials import recover_config_secrets
|
|
from hermes_cli.model_switch import apply_model_selection, switch_model
|
|
from hermes_cli.config import get_compatible_custom_providers
|
|
old = restore_policy(prepared['snapshot']['receipt']['policy'])
|
|
config, current = _current_route(authority, live, old)
|
|
reset = is_model_reset(payload)
|
|
if reset:
|
|
payload = await asyncio.to_thread(_reset_payload, authority, old, current)
|
|
if 'status' in payload:
|
|
return payload
|
|
# A pick is an explicit user action: it resolves against the owning profile's CURRENT named
|
|
# providers, as CLI/TUI ``/model`` do (#139740). A following session's ``config`` already is
|
|
# the profile's current read; every other session keeps ``config`` for its own route below.
|
|
resolution = config if old.follows_profile else await asyncio.to_thread(
|
|
_provider_resolution_config, authority, config, old.ignore_user_config)
|
|
|
|
def switch(**kwargs):
|
|
result = switch_model(**kwargs)
|
|
return result, _alias_credential(authority, result)
|
|
result, alias_credential = await asyncio.to_thread(switch, raw_input=payload['model'],
|
|
current_provider=current.provider, current_model=current.model, current_base_url=current.base_url,
|
|
current_api_key=current.api_key, explicit_provider=payload.get('provider', ''),
|
|
is_global=False, user_providers=resolution.get('providers'),
|
|
custom_providers=get_compatible_custom_providers(resolution))
|
|
if not result.success:
|
|
raise RuntimeStoreError('model_resolution_failed')
|
|
# The configured default is the user's own choice: a reset is never held behind a cost ask.
|
|
refusal = None if reset else await _selection_guard_refusal(
|
|
authority, live, payload, prepared['snapshot'], current, result)
|
|
if refusal is not None:
|
|
return refusal
|
|
frozen = old.config()
|
|
# The committed selection freezes the ONE picked endpoint (its key stays a private secret).
|
|
provider_secrets, retired, moved = ({}, (), {}) if old.follows_profile else _freeze_selected_provider(
|
|
resolution, frozen, result.target_provider)
|
|
if old.follows_profile:
|
|
# A following session switches FROM the profile's current route: measured from its creation
|
|
# endpoint, a pick on the current one read as a route change and dropped its key_env pointer.
|
|
followed = config.get('model') or {}
|
|
# An inline key (or an alias key's pointer) authenticates the route it was picked on: once the
|
|
# followed route moved, the same-route cleanup below would keep it for the new endpoint.
|
|
held = bound_route_holds(frozen.get('model'), frozen, followed, config)
|
|
# Every other route-bound key follows the profile's current route.
|
|
kept = ('api_key', 'api', 'api_key_from') if held else ()
|
|
route_keys = tuple(k for k in ROUTE_BOUND_KEYS if k not in kept)
|
|
frozen['model'] = {k: v for k, v in (frozen.get('model') or {}).items() if k not in route_keys}
|
|
# The profile's inline key and its private-secret pointer are not copied into the receipt:
|
|
# its secrets stay at the profile's own path, never re-committed under this session.
|
|
frozen['model'].update({k: followed[k] for k in route_keys
|
|
if k in followed and k not in ('api_key', 'api', 'api_key_from')})
|
|
# The one selection shape config.yaml gets (#25106): api_mode follows the target, a context pin
|
|
# and the endpoint-bound credential fields (inline key, key_env/api_key_env) drop on a route change.
|
|
# Off-loop: the context-pin check can do cold-start disk I/O.
|
|
frozen['model'] = await asyncio.to_thread(apply_model_selection, frozen.get('model'), result)
|
|
if 'reasoning' in payload:
|
|
# The level picked with the model lands in the same commit (as prepare_reasoning writes it).
|
|
from gateway.session_policy import pin_reasoning_effort
|
|
pin_reasoning_effort(frozen, payload['reasoning'], session=True)
|
|
if old.follows_profile:
|
|
# A committed /model holds while the profile still targets what it targets now (main's
|
|
# composer_override_profile); a later profile edit supersedes it.
|
|
from pathlib import Path
|
|
from gateway.run import _load_gateway_config
|
|
from gateway.session_policy import present_sections, profile_target
|
|
live = await asyncio.to_thread(
|
|
lambda: _load_gateway_config(Path(authority.db.db_path).resolve().parent / 'config.yaml'))
|
|
frozen['model']['selection_profile'] = list(profile_target(present_sections(live)))
|
|
# A direct alias's own credential is part of the selection (never ``result.api_key``, which the
|
|
# switch resolved for its probe): its env pointer is committed and re-read every turn; a literal
|
|
# key is a private config secret at the alias entry's own path (what a restarted owner re-reads)
|
|
# and the receipt keeps only a pointer to it, which config() projects into ``model.api_key``.
|
|
key_env, source, alias_key = alias_credential
|
|
key_env = key_env or _selected_provider_key_env(resolution, result.target_provider)
|
|
if key_env:
|
|
frozen['model']['key_env'] = key_env
|
|
if source:
|
|
frozen['model'].update(api_key=None, api_key_from=list(source))
|
|
elif 'api_key' not in frozen['model']:
|
|
frozen['model'].pop('api_key_from', None)
|
|
# Creation identity (request_json) remains immutable; runtime selection lives in the policy.
|
|
policy = replace(old, model=result.new_model, config_json=json.dumps(frozen))
|
|
secrets = recover_config_secrets(authority, old) if old.config_secret_ref else {}
|
|
# The picked entry's private subtree is replaced whole: a key or header its current declaration
|
|
# withdrew must not follow it to the new endpoint.
|
|
# An unrelated entry the pick displaced carries its own secrets to its new index.
|
|
secrets = {moved.get(path[:2], path[:2]) + path[2:]: value for path, value in secrets.items()}
|
|
secrets = {path: value for path, value in secrets.items() if path[:2] not in retired}
|
|
secrets.update(provider_secrets)
|
|
# A cleared field's private value must not be re-hydrated into the new route by config().
|
|
secrets = {path: value for path, value in secrets.items()
|
|
if not (len(path) == 2 and path[0] == 'model' and path[1] not in frozen['model'])}
|
|
if source:
|
|
secrets.pop(('model', 'api_key'), None)
|
|
secrets[source] = alias_key
|
|
# Re-fingerprint frozen config references because policy identity includes model.
|
|
# An explicit launch key belongs to the endpoint it authenticated, not to a provider name:
|
|
# custom -> custom on another base_url must not carry it (same route identity as config keys).
|
|
# Measured on resolved routes (a respelled provider on one endpoint is one route; a named entry
|
|
# whose base_url moved is another): the key survives only while it still authenticates the route
|
|
# the session runs now and the pick stays on that route.
|
|
key = route_launch_key(authority, old, config)
|
|
if key is None or not bound_route_holds(config.get('model'), config, frozen['model'], config):
|
|
policy = replace(policy, credential_ref=None)
|
|
key = None
|
|
policy = bind_launch_key(authority, prepared['snapshot']['receipt']['session_id'], policy, key,
|
|
config_secrets=secrets)
|
|
return dict(prepared, policy=asdict(policy))
|
|
|
|
|
|
def is_model_reset(payload):
|
|
"""``/model reset`` as a ``model`` mutation; with any other selection field it is refused, as on
|
|
every surface (``parse_model_switch_args``)."""
|
|
from hermes_cli.model_switch import (
|
|
MODEL_SWITCH_ERR_RESET_WITH_FLAGS, MODEL_SWITCH_ERROR_TEXT, MODEL_SWITCH_RESET_TOKEN)
|
|
if payload['model'].strip().lower() != MODEL_SWITCH_RESET_TOKEN:
|
|
return False
|
|
if set(payload) - {'model', 'confirm'}:
|
|
raise RuntimeStoreError('invalid_params', field='payload',
|
|
detail=MODEL_SWITCH_ERROR_TEXT[MODEL_SWITCH_ERR_RESET_WITH_FLAGS])
|
|
return True
|
|
|
|
|
|
def configured_default(authority):
|
|
"""``(model, provider)`` the owning profile's config.yaml names now (``auto`` reads as no provider)."""
|
|
from gateway.run import _resolve_gateway_model
|
|
from gateway.session_policy import profile_config
|
|
profile = profile_config(authority)
|
|
block = profile.get('model')
|
|
if not block:
|
|
return '', ''
|
|
provider = str(block.get('provider') or '') if isinstance(block, dict) else ''
|
|
return _resolve_gateway_model(profile), '' if provider.lower() == 'auto' else provider
|
|
|
|
|
|
def _reset_payload(authority, old, current):
|
|
"""``/model reset``: the pick becomes the owning profile's configured model, read fresh, so the
|
|
session runs (and, when following, keeps following) its profile again; history is kept. Returns a
|
|
``noop`` report when the session already runs that model. A safe-mode session never read the
|
|
profile, so it has no configured default to return to."""
|
|
from agent.i18n import t
|
|
model, provider = ('', '') if old.ignore_user_config else configured_default(authority)
|
|
if not model:
|
|
raise RuntimeStoreError('invalid_params', field='payload.model', detail=t('cli.model.reset_no_default'))
|
|
if model == current.model and provider in ('', current.provider):
|
|
return {'status': 'noop', 'model': model, 'note': t('cli.model.reset_none', model=model)}
|
|
return {'model': model, **({'provider': provider} if provider else {})}
|
|
|
|
|
|
def _provider_resolution_config(authority, config, bypass=False):
|
|
"""*config* with the owning profile's CURRENT named providers over its frozen ones, used only to
|
|
resolve and freeze a pick. Reads this owner's own config.yaml (never another profile's); a
|
|
frozen entry the profile no longer declares stays resolvable. A session that bypasses the
|
|
profile (``ignore_user_config``, implied by safe mode) resolves only its own frozen config.
|
|
``${VAR}`` refs expand against the profile's CURRENT ``.env``, not the daemon's start env."""
|
|
from pathlib import Path
|
|
from gateway.session_local import load_profile_config
|
|
if bypass:
|
|
return config
|
|
current = load_profile_config(Path(authority.db.db_path).resolve().parent)
|
|
if not isinstance(current, dict):
|
|
return config
|
|
resolved = dict(config)
|
|
frozen_providers, current_providers = config.get('providers'), current.get('providers')
|
|
if isinstance(frozen_providers, dict) or isinstance(current_providers, dict):
|
|
current_providers = current_providers if isinstance(current_providers, dict) else {}
|
|
slugs = {_provider_key_slug(key) for key in current_providers}
|
|
# A respelled key is the same provider: the profile's spelling replaces the frozen one.
|
|
resolved['providers'] = {**{key: entry for key, entry in (
|
|
frozen_providers if isinstance(frozen_providers, dict) else {}).items()
|
|
if _provider_key_slug(key) not in slugs}, **current_providers}
|
|
frozen_custom, current_custom = config.get('custom_providers'), current.get('custom_providers')
|
|
if isinstance(frozen_custom, list) or isinstance(current_custom, list):
|
|
current_custom = list(current_custom) if isinstance(current_custom, list) else []
|
|
names = {_custom_provider_name(entry) for entry in current_custom if isinstance(entry, dict)}
|
|
# Current entries keep their profile positions (the path cold recovery re-reads).
|
|
resolved['custom_providers'] = current_custom + [
|
|
entry for entry in (frozen_custom if isinstance(frozen_custom, list) else [])
|
|
if not isinstance(entry, dict) or _custom_provider_name(entry) not in names]
|
|
resolved['_profile_custom_count'] = len(current_custom)
|
|
return resolved
|
|
|
|
|
|
def _custom_provider_name(entry):
|
|
"""The runtime's canonical identity: ``My Provider`` and ``my-provider`` are one provider."""
|
|
from hermes_cli.providers import custom_provider_slug
|
|
return custom_provider_slug(str(entry.get('name') or ''), str(entry.get('provider_key') or ''))
|
|
|
|
|
|
def _selected_provider_key_env(config, provider):
|
|
"""Return a selected ``providers.<slug>`` credential pointer, never its secret value."""
|
|
providers = config.get('providers')
|
|
entry = providers.get(provider) if isinstance(providers, dict) else None
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
return str(entry.get('key_env') or entry.get('api_key_env') or '').strip() or None
|
|
|
|
|
|
def _freeze_selected_provider(config, frozen, provider):
|
|
"""Copy only the picked named provider's declaration into the frozen session route.
|
|
|
|
The next turn resolves from the session's own configuration, not a later profile edit. Private
|
|
fields move to the policy's secret store, as policy creation does; nothing else moves. Returns
|
|
``(secrets, retired)``: the entry's private values and every ``(section, key)`` whose previous
|
|
private subtree they replace, plus ``moved``: ``{old (section, index): new}`` for an unrelated entry
|
|
the pick displaced. A legacy entry from the profile is frozen at its profile index (the path cold
|
|
recovery re-reads); an equivalent earlier spelling is blanked, never left to win.
|
|
"""
|
|
from copy import deepcopy
|
|
from gateway.session_policy import _extract_config_secrets
|
|
from hermes_cli.runtime_provider_custom import named_custom_provider_config_path
|
|
path = named_custom_provider_config_path(provider, config)
|
|
if path is None:
|
|
return {}, (), {}
|
|
section, index = path
|
|
entries = config.get(section)
|
|
if not isinstance(entries, (dict, list)) or not isinstance(entries[index], dict):
|
|
return {}, (), {}
|
|
copied = deepcopy(entries[index])
|
|
retired, moved = {(section, index)}, {}
|
|
if section == 'providers':
|
|
previous = frozen.get(section) if isinstance(frozen.get(section), dict) else {}
|
|
stale = {key for key in previous if _provider_key_slug(key) == _provider_key_slug(index)}
|
|
frozen[section] = {**{k: v for k, v in previous.items() if k not in stale}, index: copied}
|
|
retired |= {(section, key) for key in stale}
|
|
else:
|
|
frozen_entries = list(frozen.get(section) or [])
|
|
name = _custom_provider_name(copied)
|
|
same = [i for i, entry in enumerate(frozen_entries)
|
|
if isinstance(entry, dict) and _custom_provider_name(entry) == name]
|
|
if index >= config.get('_profile_custom_count', len(entries)):
|
|
index = same[0] # a frozen-only entry stays where it is
|
|
frozen_entries += [{}] * (index + 1 - len(frozen_entries))
|
|
displaced = frozen_entries[index]
|
|
if isinstance(displaced, dict) and displaced and _custom_provider_name(displaced) != name:
|
|
moved[(section, index)] = (section, len(frozen_entries))
|
|
frozen_entries.append(displaced) # still resolvable by name, with its own secrets
|
|
for i in same:
|
|
frozen_entries[i] = {}
|
|
frozen_entries[index] = copied
|
|
retired |= {(section, i) for i in same} | {(section, index)}
|
|
frozen[section] = frozen_entries
|
|
secrets = {}
|
|
_extract_config_secrets(frozen[section][index], secrets, (section, index))
|
|
return secrets, retired, moved
|
|
|
|
|
|
def _provider_key_slug(key):
|
|
"""A ``providers:`` key's canonical identity: ``Review Prov`` and ``review-prov`` are one."""
|
|
from hermes_cli.providers import custom_provider_slug
|
|
return custom_provider_slug('', str(key))
|
|
|
|
|
|
def _alias_credential(authority, result):
|
|
"""``(key_env, source_path, literal_key)`` the direct alias a switch resolved declares for its own
|
|
endpoint, read from THIS owner's config.yaml: the file a restarted owner re-reads, never the
|
|
process-global alias cache another served profile may refill after the switch. A raw
|
|
``api_key: "${VAR}"`` is the pointer ``VAR``; ``(None, None, None)`` when it declares none."""
|
|
from pathlib import Path
|
|
from gateway.run import _load_gateway_config
|
|
from hermes_cli.config import _env_ref_var_name, read_user_config_raw
|
|
name = result.resolved_via_alias if result.success else ''
|
|
path = Path(authority.db.db_path).resolve().parent / 'config.yaml'
|
|
raw = read_user_config_raw(path) if name else {}
|
|
for section in (('model_aliases',), ('model', 'aliases')):
|
|
model = raw.get('model') # `model: name` (a scalar) is valid config and declares no aliases
|
|
entries = raw.get(section[0]) if len(section) == 1 else (model.get('aliases') if isinstance(model, dict) else None)
|
|
for written, entry in (entries.items() if isinstance(entries, dict) else ()):
|
|
if not isinstance(entry, dict) or not entry.get('model') or str(written).strip().lower() != name:
|
|
continue
|
|
key = str(entry.get('api_key') or '').strip()
|
|
pointer = _env_ref_var_name(key[2:-1]) if key.startswith('${') and key.endswith('}') else None
|
|
if pointer:
|
|
return pointer, None, None
|
|
if not key:
|
|
return str(entry.get('key_env') or '').strip() or None, None, None
|
|
value = _load_gateway_config(path)
|
|
for part in (*section, written, 'api_key'):
|
|
value = value.get(part) if isinstance(value, dict) else None
|
|
if not value:
|
|
raise RuntimeStoreError('model_resolution_failed')
|
|
return None, (*section, written, 'api_key'), value
|
|
return None, None, None
|
|
|
|
|
|
def _persisted_selection_context(authority, snapshot, current):
|
|
from agent.usage_anchor import persisted_anchor_tokens
|
|
from hermes_cli.model_selection_guards import SelectionContext
|
|
target = snapshot.get('target') or snapshot['receipt']['session_id']
|
|
tokens = persisted_anchor_tokens(authority.db, target, authority.db.get_messages_as_conversation(target))
|
|
return SelectionContext(context_tokens=tokens, current_model=current.model or None) if tokens else None
|
|
|
|
|
|
async def _selection_guard_refusal(authority, live, payload, snapshot, current, result):
|
|
"""The shared selection-guard registry (cost, data-policy, large-context switch) runs before the
|
|
commit. A flagged target is refused with ``status: confirmation_required`` and a ``confirm``
|
|
token bound to the resolved target, the selection it replaces and the session revision; nothing
|
|
is written. Only that exact token in ``payload['confirm']`` applies it: a stale or foreign
|
|
token (target re-resolved elsewhere, another switch landed, a turn settled) gets a fresh
|
|
refusal, never a silent apply."""
|
|
import hashlib
|
|
from hermes_cli.model_selection_guards import combined_message, selection_context_for_agent, selection_warnings
|
|
from hermes_cli.route_identity import normalize_route_base_url
|
|
lookup = (getattr(authority.runner, '_resident_agent_for', None)
|
|
or getattr(authority.runner, '_cached_agent_for', None))
|
|
agent = lookup(live.route) if live is not None and callable(lookup) else None
|
|
context = selection_context_for_agent(agent)
|
|
if context is None:
|
|
# No resident agent (evicted by the previous switch, or an owner restart): the session's
|
|
# persisted usage anchor still measures the context at stake, so the large-context guard
|
|
# does not go quiet on exactly the switch that follows another one.
|
|
context = await asyncio.to_thread(_persisted_selection_context, authority, snapshot, current)
|
|
# Off-loop: pricing lookups may hit models.dev on a cache miss.
|
|
warnings = await asyncio.to_thread(
|
|
selection_warnings, result.new_model, provider=result.target_provider,
|
|
base_url=result.base_url or current.base_url, api_key=result.api_key or current.api_key,
|
|
model_info=result.model_info, selection_context=context)
|
|
if not warnings:
|
|
return None
|
|
binding = {'target': [result.new_model, result.target_provider, normalize_route_base_url(result.base_url),
|
|
result.api_mode or ''],
|
|
'current': [current.model, current.provider, normalize_route_base_url(current.base_url)],
|
|
'session': [snapshot['target'], snapshot['target_revision']],
|
|
'guards': sorted(w.kind for w in warnings)}
|
|
token = hashlib.sha256(json.dumps(binding, sort_keys=True).encode()).hexdigest()
|
|
if payload.get('confirm') == token:
|
|
return None
|
|
# A fresh dict: the prepared snapshot carries the frozen policy and never leaves the owner. No
|
|
# ``model`` key: a client that predates the exchange must not paint the target as applied.
|
|
return {'status': 'confirmation_required', 'confirm_required': True, 'confirm': token,
|
|
'target_model': result.new_model, 'target_provider': result.target_provider,
|
|
'confirm_message': combined_message(warnings),
|
|
'warnings': [{'kind': w.kind, 'title': w.title, 'message': w.message} for w in warnings]}
|