"""Agent-construction inputs the owner resolves for a managed worker (``construct_v1``). The in-process turn passes these to ``AIAgent`` from the owner's runner state; a fresh worker cannot reconstruct them (its config readers serve the frozen session snapshot, and a bypass worker must never reload the profile), so the owner resolves them per turn, mode-appropriately, and they ride the hydrated request beside ``turn_v1``. The child validates the closed shape. """ import json from pathlib import Path KEYS = frozenset({'fallback_model', 'ephemeral_system_prompt', 'prefill_messages'}) def construct_inputs(authority, policy, chat_id): """Owner side, per turn (off the owner loop: config reads).""" from gateway.session_authorities import owner_scope with owner_scope(authority): return {'fallback_model': _fallback_chain(authority, policy), 'ephemeral_system_prompt': _ephemeral_prompt(authority, policy, chat_id), 'prefill_messages': session_prefill(authority, policy, chat_id)} def _fallback_chain(authority, policy): """The chain ``_build_fresh_agent`` gives an in-process turn. Ordinary sessions keep the profile refresh semantics (re-read per turn, last-known-good per home); a config-only session takes its frozen explicit snapshot (never the profile); safe mode carries none.""" if policy.safe_mode: return None if policy.ignore_user_config: from hermes_cli.fallback_config import get_fallback_chain return get_fallback_chain(policy.config(authority)) or None refresh = getattr(authority.runner, '_refresh_fallback_model', None) return refresh() if refresh is not None else None def _ephemeral_prompt(authority, policy, chat_id): """``TurnRunner``'s combined ephemeral prompt for a LOCAL turn (no platform context block): the gateway prompt (``agent.system_prompt`` / ``display.personality`` / channel override) plus the frozen ``-s`` skills blocks. API-time only, never part of the persisted system prefix. A bypass session never reads the profile: only its frozen launch additions apply.""" configured = None resolve = getattr(authority.runner, '_get_system_prompt_for_channel', None) if not policy.ignore_user_config and resolve is not None: from gateway.config import Platform with policy.client_env_scope(): configured = resolve(Platform.LOCAL, chat_id or '') return '\n\n'.join(p for p in (configured, policy.skills_prompt) if p) or None def session_routing(authority, policy): """The session's ``provider_routing``: its own profile's, as of its creation (main's CLI read it once at startup). Never the runner's daemon-start copy.""" routing = policy.config(authority, secrets=False).get('provider_routing') return routing if isinstance(routing, dict) else {} def session_prefill(authority, policy, session_id): """The session's prefill examples, for the in-process turn and a managed worker alike: the snapshot ``stage_session_prefill`` took when the session was created (main's CLI read the file once at startup). A session this owner did not create (restored after a restart, a branch) reads it on first use and keeps that. A bypass session never loads it.""" if policy.ignore_user_config: return None held = _held_prefill(authority) if session_id not in held: held.setdefault(session_id, (_read_prefill(authority, policy),)) return held[session_id][0] class StagedReceipt(dict): """A creation receipt plus owner memory ``commit_local_session`` publishes only from the transaction that creates the session (never persisted: the receipt itself is the dict).""" on_create: '_PrefillPublication | None' = None # The request's own ``client_env`` (stage_local_session), for an overlapping create's retry check. client_request: 'dict | None' = None class _PrefillPublication: def __init__(self, authority, policy, session_id): # A box per attempt: ownership is identity, even when two attempts read the same contents. self.authority, self.policy, self.session_id, self.box = authority, policy, session_id, None def read(self): """This attempt's snapshot, read by its commit before the transaction (off the owner loop).""" self.box = (_read_prefill(self.authority, self.policy),) def publish(self): _held_prefill(self.authority)[self.session_id] = self.box def withdraw(self): held = _held_prefill(self.authority) if held.get(self.session_id) is self.box: held.pop(self.session_id, None) def stage_session_prefill(authority, policy, receipt): """Stage a new session's prefill snapshot: the ``prefill_messages_file`` of its config, relative to its own profile home, read by this attempt's commit (``commit_local_session``, which the async creates run in ``tracked_write``'s worker thread), never on the owner loop. The snapshot is held in the authority's memory only if this attempt's commit creates the session: an overlapping retry or conflicting create never replaces the winner's, and a refused or failed create leaves nothing. Never evicted by other sessions; ``release_session_prefill`` drops it with the session.""" staged = StagedReceipt(receipt) if not policy.ignore_user_config: staged.on_create = _PrefillPublication(authority, policy, receipt['session_id']) return staged def release_session_prefill(authority, session_ids): held = getattr(authority, '_local_prefill', None) or {} for sid in session_ids: held.pop(str(sid), None) def _held_prefill(authority): held = getattr(authority, '_local_prefill', None) if held is None: held = authority._local_prefill = {} return held def _read_prefill(authority, policy): from hermes_cli.cli_config_load import _load_prefill_messages, _resolve_prefill_messages_file home = Path(str(authority.profile_id)) if not home.is_absolute(): from hermes_constants import get_hermes_home home = get_hermes_home() with policy.client_env_scope(): # HERMES_PREFILL_MESSAGES_FILE: a local session's client's path = _resolve_prefill_messages_file(policy.config(authority, secrets=False)) return _load_prefill_messages(path, home) or None def construct_kwargs(frame): """Child side: the validated ``AIAgent`` keyword arguments; a frame without the object (older owner) constructs exactly as before.""" inputs = json.loads(frame['policy'].get('request_json') or '{}').get('construct_v1') if inputs is None: return {} if (not isinstance(inputs, dict) or set(inputs) != KEYS or not _dict_list(inputs['fallback_model']) or not _dict_list(inputs['prefill_messages']) or not isinstance(inputs['ephemeral_system_prompt'], (str, type(None)))): raise ValueError('invalid_managed_worker_bootstrap') return dict(inputs) def _dict_list(value): return value is None or (isinstance(value, list) and all(isinstance(e, dict) for e in value)) def route_runtime(policy, frozen, model, key, *, fallback=True): """``(runtime, fallback_entry)`` for a launch-policy route: the session's FROZEN config and launch credential, walking its own fallback chain on an AuthError unless ``fallback=False`` (a session control asking which route the session itself runs). The one resolver an in-process turn (``_resolve_policy_agent_runtime``) and a managed worker (``worker_route``) share.""" from hermes_cli.runtime_provider import ( frozen_runtime_config, resolve_runtime_provider, resolve_runtime_with_fallback, ) from hermes_cli.runtime_provider_custom import _resolve_named_custom_runtime provider = (frozen.get('model') or {}).get('provider') launch_url = json.loads(policy.request_json).get('base_url') if launch_url: # The creation request is immutable identity, not the current selection: once a canonical # model mutation moved the route off the launch endpoint, the policy's selection wins. from hermes_cli.route_identity import normalize_route_base_url if normalize_route_base_url(launch_url) != normalize_route_base_url((frozen.get('model') or {}).get('base_url')): launch_url = None # Every rung reads the session's FROZEN config, never live config.yaml: a later # ``model.base_url`` edit must not carry this route's frozen credential to the new host # (R2-M2). The auth-store pool is not config, so refresh/rotation still applies. with frozen_runtime_config(frozen): runtime = _resolve_named_custom_runtime(requested_provider=provider, explicit_api_key=key, explicit_base_url=launch_url, target_model=model, config=frozen) if runtime is not None: return runtime, None # Only a bound launch key is explicit: the frozen ``model.api_key`` is config, which the # resolver already reads from the frozen scope, so a URL-matched credential pool still # serves (and rotates on) the frozen endpoint. # Same resolution-time walker the in-process one-shot used (#81209): an AuthError # from the frozen primary (expired token, Portal down, exhausted pool) tries the # route's own ``fallback_providers`` before the turn is refused. Only a launch URL is # explicit (classic one-shot parity): the frozen ``model.base_url`` is resolved by # the provider chain, which keeps the credential pool an explicit URL would drop. if not fallback: # Takes no config argument: it reads `frozen` only through the frozen_runtime_config scope above, so it must stay inside that `with`. return resolve_runtime_provider(requested=provider, target_model=model, explicit_base_url=launch_url, explicit_api_key=key), None return resolve_runtime_with_fallback(frozen, requested=provider, explicit_api_key=key, explicit_base_url=launch_url, target_model=model) def worker_route(policy, construct): """Child side: the ``api_key`` / ``base_url`` / ``api_mode`` overrides for the worker's agent, resolved as the in-process turn resolves its route (``route_runtime`` with the launch key, if one was bound), from this process's profile home (its HERMES_HOME and ``.env``). The frozen ``model.base_url`` alone is not the route: a per-model api_mode (opencode-go Responses models) and a resolved endpoint (Bedrock's regional URL) come only from resolution. A fallback entry's route belongs to another provider and model, so the primary never carries it; the agent's own fallback chain switches them together (#139155). A primary AuthError walks the chain the agent will actually get: the owner's per-turn ``construct_v1`` chain (an ordinary session's refreshed profile fallbacks) when it was sent, the frozen snapshot's otherwise (older owner).""" frozen, model = policy.effective() if 'fallback_model' in construct: frozen = {**frozen, 'fallback_providers': construct['fallback_model'] or [], 'fallback_model': None} runtime, fallback_entry = route_runtime(policy, frozen, model, construct['api_key']) if fallback_entry is not None: return {} api_mode = runtime.get('api_mode') # A built-in provider's chat_completions is the resolver's default unless model.api_mode set it: None # leaves the agent's model-aware upgrade (GPT-5 on OpenRouter to Responses). A custom route's mode # is its endpoint's own (providers./custom_providers[] or URL-detected), which the in-process # turn's ``custom`` provider never upgrades; the worker's ``custom:`` would. if (api_mode == 'chat_completions' and runtime.get('provider') != 'custom' and not (frozen.get('model') or {}).get('api_mode')): api_mode = None return {'api_key': construct['api_key'] or runtime.get('api_key'), 'base_url': runtime.get('base_url'), 'api_mode': api_mode} def checkpoint_agent_kwargs(config): """``AIAgent`` checkpoint arguments from a config dict (every turn: the gateway's live config, a managed worker's frozen session snapshot). The gateway bypasses ``load_config()``, so defaults are here; legacy ``checkpoints: true`` works.""" cp_cfg = config.get("checkpoints", {}) if isinstance(config, dict) else {} if isinstance(cp_cfg, bool): cp_cfg = {"enabled": cp_cfg} elif not isinstance(cp_cfg, dict): cp_cfg = {} from hermes_cli.config import DEFAULT_CONFIG defaults = DEFAULT_CONFIG["checkpoints"] return { "checkpoints_enabled": cp_cfg.get("enabled", defaults["enabled"]), "checkpoint_max_snapshots": cp_cfg.get("max_snapshots", defaults["max_snapshots"]), "checkpoint_max_total_size_mb": cp_cfg.get("max_total_size_mb", defaults["max_total_size_mb"]), "checkpoint_max_file_size_mb": cp_cfg.get("max_file_size_mb", defaults["max_file_size_mb"])} def routing_agent_kwargs(config, model, provider, base_url): """The provider-routing ``AIAgent`` arguments every in-process surface passes (the CLI's ``provider_routing`` / ``openrouter.min_coding_score`` / ``agent.service_tier``, a static fast tier's request overrides), from a managed worker's frozen session config.""" from agent.fast_mode import STATIC_TIERS, parse_service_tier from hermes_cli.models import resolve_fast_mode_overrides pr = config.get('provider_routing') or {} score = (config.get('openrouter') or {}).get('min_coding_score') tier = parse_service_tier((config.get('agent') or {}).get('service_tier', '')) overrides = resolve_fast_mode_overrides(model, provider=provider, base_url=base_url, tier=tier) if tier in STATIC_TIERS else None return {'providers_allowed': pr.get('only'), 'providers_ignored': pr.get('ignore'), 'providers_order': pr.get('order'), 'provider_sort': pr.get('sort'), 'provider_require_parameters': pr.get('require_parameters', False), 'provider_data_collection': pr.get('data_collection'), 'service_tier': tier, 'request_overrides': overrides, 'openrouter_min_coding_score': score if isinstance(score, (int, float)) and 0 <= score <= 1 else None}