mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
providers/base.py auth_type values and the client-construction hook's pointer (now the model-provider plugin developer guide), gateway/session_cli_turn.py and tools/schema_sanitizer.py comments no longer name a specific provider.
228 lines
13 KiB
Python
228 lines
13 KiB
Python
"""Per-admission turn inputs the classic CLI sends with ``prompt.submit`` (CALLMAP W2).
|
|
|
|
Main's HermesCLI calls ``AIAgent.run_conversation`` with an API-only message (voice prefix,
|
|
model-switch / skills-reload notes) while persisting the clean text, names the turn's author for
|
|
memory attribution, and runs ``/model --once`` and ``/moa`` as a one-turn runtime swap. Over the
|
|
session authority those are facts about ONE admitted turn, committed with the admission row as
|
|
``cli_turn_v1`` so a retry or a queued drain runs exactly what was admitted, and bound for that
|
|
admission's execution only, never for a later turn.
|
|
"""
|
|
from contextlib import contextmanager
|
|
from contextvars import ContextVar
|
|
|
|
from hermes_state_runtime import RuntimeStoreError
|
|
|
|
_SUBMIT_FIELDS = ('turn_author', 'api_text', 'model_override', 'clarify_batch')
|
|
_API_TEXT_LIMIT = 1_000_000
|
|
_cli_turn: ContextVar[tuple | None] = ContextVar('cli_turn', default=None)
|
|
|
|
|
|
def submit_cli_turn_fields(params):
|
|
"""The raw per-turn CLI fields of a ``prompt.submit`` request, forwarded to admission."""
|
|
return {key: params[key] for key in _SUBMIT_FIELDS if key in params}
|
|
|
|
|
|
def admit_cli_turn(params):
|
|
"""Wire fields -> committed ``cli_turn_v1`` (``{}`` when absent); malformed values are refused."""
|
|
committed = {}
|
|
if params.get('turn_author') is not None:
|
|
from agent.turn_author import parse_turn_author
|
|
author = parse_turn_author(params['turn_author'])
|
|
if author is None:
|
|
raise RuntimeStoreError('invalid_params')
|
|
committed['turn_author'] = author
|
|
api_text = params.get('api_text')
|
|
if api_text is not None:
|
|
if not isinstance(api_text, str) or len(api_text) > _API_TEXT_LIMIT:
|
|
raise RuntimeStoreError('invalid_params')
|
|
if api_text:
|
|
committed['api_text'] = api_text
|
|
override = params.get('model_override')
|
|
if override is not None:
|
|
from hermes_constants import parse_reasoning_effort
|
|
if (not isinstance(override, dict) or set(override) - {'model', 'provider', 'reasoning'}
|
|
or not isinstance(override.get('model'), str) or not override['model'].strip()
|
|
or not isinstance(override.get('provider', ''), str)
|
|
or ('reasoning' in override and (not isinstance(override['reasoning'], str)
|
|
or parse_reasoning_effort(override['reasoning']) is None))):
|
|
raise RuntimeStoreError('invalid_params')
|
|
# ``reasoning``: ``/model B --once --reasoning X`` runs X for this admission only; the session's
|
|
# reasoning policy (and its ``session_pins``) is never written.
|
|
committed['model_override'] = {'model': override['model'].strip(),
|
|
**({'provider': override['provider']} if override.get('provider') else {}),
|
|
**({'reasoning': override['reasoning']} if 'reasoning' in override else {})}
|
|
batch = params.get('clarify_batch')
|
|
if batch is not None and type(batch) is not bool:
|
|
raise RuntimeStoreError('invalid_params')
|
|
if batch:
|
|
# The submitting viewer answers the clarify tool's whole batch in one ``clarify`` server
|
|
# request (main's batch panel: classic CLI, Ink TUI). Viewers that never set it (an ACP
|
|
# editor, a raw WebSocket client) answer per-question cards.
|
|
committed['clarify_batch'] = True
|
|
return {'cli_turn_v1': committed} if committed else {}
|
|
|
|
|
|
@contextmanager
|
|
def cli_turn_scope(admission_id, committed):
|
|
token = _cli_turn.set((admission_id, committed) if committed else None)
|
|
try:
|
|
yield
|
|
finally:
|
|
_cli_turn.reset(token)
|
|
|
|
|
|
def cli_turn(message_id):
|
|
"""The committed CLI turn fields of the admission whose event id is ``message_id``; ``{}`` for
|
|
any other turn, so a follow-up drained on the same task never inherits them."""
|
|
bound = _cli_turn.get()
|
|
if bound is None or message_id is None or str(message_id) != bound[0]:
|
|
return {}
|
|
return bound[1]
|
|
|
|
|
|
def rebind_api_text(message_id, text):
|
|
"""This admission's ``api_text`` with its ``@`` references expanded, for the rest of this execution
|
|
(``session_context_refs.turn_references``). The committed row keeps what the CLI sent, so a retry
|
|
expands it afresh rather than twice."""
|
|
bound = _cli_turn.get()
|
|
if bound is not None and message_id is not None and str(message_id) == bound[0]:
|
|
_cli_turn.set((bound[0], {**bound[1], 'api_text': text}))
|
|
|
|
|
|
def one_turn_runtime(override, model, runtime_kwargs, config):
|
|
"""``(model, runtime_kwargs)`` of a committed ``model_override`` for this one turn, resolved by the
|
|
same ``switch_model`` pipeline ``/model`` uses against the session's current route. Nothing is
|
|
written: the session's route, policy and cached agent are untouched, so the next turn runs its own
|
|
route again (main's ``/model --once`` restore). Raises ``RuntimeError`` with the switch's own
|
|
sentence when the target cannot be resolved; the caller fails the turn with it."""
|
|
from hermes_cli.config import get_compatible_custom_providers
|
|
from hermes_cli.model_switch import switch_model
|
|
result = switch_model(
|
|
raw_input=override['model'], current_provider=runtime_kwargs.get('provider') or '',
|
|
current_model=model or '', current_base_url=runtime_kwargs.get('base_url') or '',
|
|
current_api_key=runtime_kwargs.get('api_key') or '', is_global=False,
|
|
explicit_provider=override.get('provider', ''), user_providers=(config or {}).get('providers'),
|
|
custom_providers=get_compatible_custom_providers(config or {}))
|
|
if not result.success:
|
|
raise RuntimeError(result.error_message or f"model {override['model']!r} could not be resolved")
|
|
runtime = dict(runtime_kwargs)
|
|
current_provider = runtime_kwargs.get('provider') or ''
|
|
same_provider = (result.target_provider or '').strip().lower() == current_provider.strip().lower()
|
|
# Only a ``model_aliases`` entry with its own ``base_url`` routes away from the session, as switch_model
|
|
# applied it to this result (``_apply_direct_alias_endpoint``); a built-in shortcut, an endpointless entry
|
|
# or a process provider's declared id names a model like a plain pick.
|
|
if same_provider and not result.alias_endpoint_applied:
|
|
# A plain model pick on the session's provider: the explicit provider re-resolved that provider's
|
|
# default endpoint and key, and the session already has both (a mirror, a proxy, a pool), so keep
|
|
# them. The wire follows the target only where the provider picks it per model (a profile's
|
|
# ``api_mode_for_model``, Nous Portal, OpenCode; ``_rederive_per_model_route``); otherwise ``result.api_mode`` describes the
|
|
# default host, not this session's endpoint, and the session's wire stays.
|
|
from hermes_cli.model_switch import model_derived_api_mode
|
|
from hermes_cli.models import normalize_opencode_base_url, opencode_provider_family
|
|
if model_derived_api_mode(result.target_provider, result.new_model) is not None and result.api_mode:
|
|
runtime['api_mode'] = result.api_mode
|
|
api_mode = runtime.get('api_mode') or None
|
|
runtime['api_mode'] = api_mode
|
|
if runtime.get('base_url') and opencode_provider_family(result.target_provider):
|
|
runtime['base_url'] = normalize_opencode_base_url(result.target_provider, api_mode, runtime['base_url'])
|
|
else:
|
|
# A different provider, or an alias that names its own endpoint and key (``model_aliases``), does
|
|
# not keep this session's pool, endpoint, or key: the route ``switch_model`` resolved runs whole.
|
|
runtime.update(credential_pool=None, command=None, args=[],
|
|
api_key=result.api_key, base_url=result.base_url, api_mode=result.api_mode or None,
|
|
request_overrides=dict(result.request_overrides or {}),
|
|
capabilities=dict(result.runtime_capabilities or {}))
|
|
runtime.update(provider=result.target_provider, requested_provider=result.target_provider)
|
|
return result.new_model, runtime
|
|
|
|
|
|
# run_conversation keys main's HermesCLI reads that the gateway's own projection does not carry
|
|
# (CALLMAP §3.3 step 4): the transform-preview original, the billing descriptor and the turn's
|
|
# last reasoning / preview flags.
|
|
_RESULT_PASSTHROUGH = ('pre_transform_response', 'billing_block', 'last_reasoning', 'response_previewed',
|
|
'response_transformed', 'turn_exit_reason')
|
|
|
|
|
|
def classic_cli_turn(turn):
|
|
"""A local-viewer turn on a classic-CLI surface session (``cli`` / ``oneshot`` / ``tool``): the
|
|
viewer is main's HermesCLI, which reads the run_conversation-shaped result itself."""
|
|
from gateway.run_turn_progress import local_viewer_turn
|
|
if not local_viewer_turn(turn):
|
|
return False
|
|
from gateway.session_policy import policy_for_source
|
|
try:
|
|
policy = policy_for_source(turn._runner, turn._ctx.source)
|
|
except RuntimeStoreError: # a route whose frozen policy is not restored: not a CLI viewer turn
|
|
return False
|
|
return policy is not None and policy.platform == 'cli'
|
|
|
|
|
|
def clarify_batch_turn(turn):
|
|
"""The clarify tool's batch goes to main's batch panel as one request: a local-viewer turn whose
|
|
submitter declared it answers the whole set (``clarify_batch`` committed with its admission).
|
|
Main's classic CLI, Ink TUI and Desktop all showed every question at once (TUI-18); a viewer that
|
|
never asks (an ACP editor, a raw WebSocket client) keeps one ``clarify.request`` card per question."""
|
|
from gateway.run_turn_progress import local_viewer_turn
|
|
return local_viewer_turn(turn) and bool(
|
|
cli_turn(getattr(turn._ctx, 'inbound_message_id', None)).get('clarify_batch'))
|
|
|
|
|
|
def take_cli_result_fields(result, *, aux_before=None, session_db=None, session_id=None):
|
|
"""The receipt-only fields of a classic CLI turn, taken from the agent's raw result.
|
|
|
|
``pending_steer`` is REMOVED from ``result``: main's CLI queues a leftover /steer as its own
|
|
next turn (``delivering leftover steer``), so the gateway must not also run it as a follow-up
|
|
inside this admission. ``final_response`` is the agent's raw text: main renders its own
|
|
``chat_error_copy`` for an empty failed turn instead of the gateway's chat copy."""
|
|
if not isinstance(result, dict):
|
|
return {}
|
|
fields = {key: result.get(key) for key in _RESULT_PASSTHROUGH if key in result}
|
|
fields['final_response'] = result.get('final_response') or ''
|
|
steer = result.pop('pending_steer', None)
|
|
fields['pending_steer'] = steer or None
|
|
if aux_before is not None:
|
|
from hermes_cli.oneshot import _attach_auxiliary_usage
|
|
holder = {'session_id': result.get('session_id') or session_id}
|
|
_attach_auxiliary_usage(holder, session_db, aux_before, fallback_session_id=session_id)
|
|
fields['auxiliary_usage'] = holder.get('auxiliary_usage') or {}
|
|
return fields
|
|
|
|
|
|
def one_turn_route(turn, policy, model, runtime_kwargs):
|
|
"""The executing admission's ``model_override`` applied to the resolved route (unchanged without one)."""
|
|
override = cli_turn(turn._ctx.inbound_message_id).get('model_override')
|
|
if not override:
|
|
return model, runtime_kwargs
|
|
from gateway.session_authorities import active_authority
|
|
config = policy.config(active_authority(turn._runner)) if policy else turn._ctx.user_config
|
|
return one_turn_runtime(override, model, runtime_kwargs, config)
|
|
|
|
|
|
def one_turn_reasoning(turn, reasoning_config):
|
|
"""The executing admission's one-turn reasoning level (``model_override.reasoning``), else
|
|
*reasoning_config* (the session's)."""
|
|
override = cli_turn(turn._ctx.inbound_message_id).get('model_override') or {}
|
|
if 'reasoning' not in override:
|
|
return reasoning_config
|
|
from hermes_constants import parse_reasoning_effort
|
|
return parse_reasoning_effort(override['reasoning']) or reasoning_config
|
|
|
|
|
|
def run_classic_cli_turn(turn, run):
|
|
"""``run()`` (the turn's run_conversation) for any turn; on a classic CLI turn the receipt-only
|
|
fields are taken from its raw result and kept on ``turn`` for the committed receipt."""
|
|
if not classic_cli_turn(turn):
|
|
return run()
|
|
from gateway.session_finite import finite_turn_required
|
|
aux_before = cli_db = None
|
|
if finite_turn_required() is True:
|
|
# `chat -q --usage-file` / stream-json: this turn's auxiliary spend, as oneshot.py read it.
|
|
from hermes_cli.oneshot import _auxiliary_usage
|
|
runner_db = turn._runner._session_db
|
|
cli_db = getattr(runner_db, "_db", runner_db)
|
|
aux_before = _auxiliary_usage(cli_db, turn._ctx.session_id)
|
|
result = run()
|
|
turn._cli_result_fields = take_cli_result_fields(
|
|
result, aux_before=aux_before, session_db=cli_db, session_id=turn._ctx.session_id)
|
|
return result
|