diff --git a/agent/agent_init.py b/agent/agent_init.py index e1d219c62c63..9aaef3015568 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -346,6 +346,7 @@ def init_agent( checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, + fast_auto_on_seconds: float = 60.0, ): """ Initialize the AI Agent. @@ -629,6 +630,11 @@ def init_agent( agent.max_tokens = max_tokens # None = use model default agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter) agent.service_tier = service_tier + from agent.fast_mode import normalize_fast_auto_on_seconds + agent.fast_auto_on_seconds = normalize_fast_auto_on_seconds(fast_auto_on_seconds) + agent._fast_mode_turn_started_at = None + agent._fast_mode_turn_eligible = False + agent._fast_mode_turn_mode = None agent.request_overrides = dict(request_overrides or {}) agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns agent._force_ascii_payload = False diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 52ec2e02c256..b48a8013325d 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -332,6 +332,69 @@ def _supports_fast_mode(model: str) -> bool: # See https://platform.claude.com/docs/en/build-with-claude/fast-mode _FAST_MODE_BETA = "fast-mode-2026-02-01" + +def _apply_fast_mode_to_kwargs( + kwargs: Dict[str, Any], + *, + enabled: bool, + model: str, + base_url: str | None, + is_oauth: bool, + drop_context_1m_beta: bool = False, +) -> Dict[str, Any]: + """Return kwargs with Anthropic fast-mode metadata applied or removed. + + This is safe to call again immediately before dispatch. It preserves + unrelated ``extra_body`` fields and headers while ensuring an expired + dynamic fast window cannot retain either ``speed=fast`` or the matching + beta header assembled earlier in the request pipeline. + """ + kwargs = dict(kwargs) + extra_body = dict(kwargs.get("extra_body") or {}) + extra_body.pop("speed", None) + if extra_body: + kwargs["extra_body"] = extra_body + else: + kwargs.pop("extra_body", None) + + extra_headers = dict(kwargs.get("extra_headers") or {}) + existing_betas = [ + beta.strip() + for beta in str(extra_headers.get("anthropic-beta") or "").split(",") + if beta.strip() and beta.strip() != _FAST_MODE_BETA + ] + if existing_betas: + extra_headers["anthropic-beta"] = ",".join(existing_betas) + else: + extra_headers.pop("anthropic-beta", None) + if extra_headers: + kwargs["extra_headers"] = extra_headers + else: + kwargs.pop("extra_headers", None) + + if not ( + enabled + and not _is_third_party_anthropic_endpoint(base_url) + and _supports_fast_mode(model) + ): + return kwargs + + kwargs.setdefault("extra_body", {})["speed"] = "fast" + betas = [ + *existing_betas, + *_common_betas_for_base_url( + base_url, + drop_context_1m_beta=drop_context_1m_beta, + ), + ] + if is_oauth: + betas.extend(_OAUTH_ONLY_BETAS) + betas.append(_FAST_MODE_BETA) + kwargs.setdefault("extra_headers", {})["anthropic-beta"] = ",".join( + dict.fromkeys(betas) + ) + return kwargs + # Additional beta headers required for OAuth/subscription auth. # Matches what Claude Code (and pi-ai / OpenCode) send. _OAUTH_ONLY_BETAS = [ @@ -2683,24 +2746,14 @@ def _to_oauth_wire_name(name: str) -> str: # Opus 4.6 — Opus 4.7 and other models 400 on the speed parameter. # Only for native Anthropic endpoints — third-party providers would # reject the unknown beta header and speed parameter. - if ( - fast_mode - and not _is_third_party_anthropic_endpoint(base_url) - and _supports_fast_mode(model) - ): - kwargs.setdefault("extra_body", {})["speed"] = "fast" - # Build extra_headers with ALL applicable betas (the per-request - # extra_headers override the client-level anthropic-beta header). - betas = list(_common_betas_for_base_url( - base_url, - drop_context_1m_beta=drop_context_1m_beta, - )) - if is_oauth: - betas.extend(_OAUTH_ONLY_BETAS) - betas.append(_FAST_MODE_BETA) - kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)} - - return kwargs + return _apply_fast_mode_to_kwargs( + kwargs, + enabled=fast_mode, + model=model, + base_url=base_url, + is_oauth=is_oauth, + drop_context_1m_beta=drop_context_1m_beta, + ) # Keys that belong exclusively to the OpenAI Responses / Codex API shape. diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 45a88f8decf0..267977588774 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -818,7 +818,10 @@ def _call(): def build_api_kwargs(agent, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" + from agent.fast_mode import effective_request_overrides + tools_for_api = agent.tools + request_overrides = effective_request_overrides(agent) if agent.api_mode == "anthropic_messages": _transport = agent._get_transport() @@ -838,7 +841,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: preserve_dots=agent._anthropic_preserve_dots(), context_length=ctx_len, base_url=getattr(agent, "_anthropic_base_url", None), - fast_mode=(agent.request_overrides or {}).get("speed") == "fast", + fast_mode=request_overrides.get("speed") == "fast", drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)), ) @@ -913,7 +916,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: session_id=getattr(agent, "session_id", None), max_tokens=agent.max_tokens, timeout=agent._resolved_api_call_timeout(), - request_overrides=agent.request_overrides, + request_overrides=request_overrides, is_github_responses=is_github_responses, is_codex_backend=is_codex_backend, is_xai_responses=is_xai_responses, @@ -1015,7 +1018,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: ephemeral_max_output_tokens=_ephemeral_out, max_tokens_param_fn=agent._max_tokens_param, reasoning_config=agent.reasoning_config, - request_overrides=agent.request_overrides, + request_overrides=request_overrides, session_id=getattr(agent, "session_id", None), provider_profile=_profile, ollama_num_ctx=agent._ollama_num_ctx, @@ -1047,7 +1050,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: ephemeral_max_output_tokens=_ephemeral_out, max_tokens_param_fn=agent._max_tokens_param, reasoning_config=agent.reasoning_config, - request_overrides=agent.request_overrides, + request_overrides=request_overrides, session_id=getattr(agent, "session_id", None), model_lower=(agent.model or "").lower(), is_openrouter=_is_or, @@ -1747,6 +1750,11 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: messages.append({"role": "user", "content": summary_request}) try: + from agent.fast_mode import ( + effective_fast_mode_overrides, + revalidate_fast_mode_request, + ) + # Build API messages, stripping internal-only fields # (finish_reason, reasoning) that strict APIs like Mistral reject with 422 _needs_sanitize = agent._should_sanitize_tool_calls() @@ -1836,6 +1844,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if agent.api_mode == "codex_responses": codex_kwargs = agent._build_api_kwargs(api_messages) codex_kwargs.pop("tools", None) + codex_kwargs = revalidate_fast_mode_request(agent, codex_kwargs) summary_response = agent._run_codex_stream(codex_kwargs) _ct_sum = agent._get_transport() _cnr_sum = _ct_sum.normalize_response(summary_response) @@ -1905,14 +1914,20 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if agent.api_mode == "anthropic_messages": _tsum = agent._get_transport() + _summary_overrides = effective_fast_mode_overrides(agent) _ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, is_oauth=agent._is_anthropic_oauth, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + context_length=getattr(agent.context_compressor, "context_length", None), + base_url=getattr(agent, "_anthropic_base_url", None), + fast_mode=_summary_overrides.get("speed") == "fast", + drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False))) summary_response = agent._anthropic_messages_create(_ant_kw) _summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_summary_result.content or "").strip() else: + summary_kwargs.update(effective_fast_mode_overrides(agent)) summary_response = agent._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs) _summary_result = agent._get_transport().normalize_response(summary_response) final_response = (_summary_result.content or "").strip() @@ -1929,16 +1944,22 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if agent.api_mode == "codex_responses": codex_kwargs = agent._build_api_kwargs(api_messages) codex_kwargs.pop("tools", None) + codex_kwargs = revalidate_fast_mode_request(agent, codex_kwargs) retry_response = agent._run_codex_stream(codex_kwargs) _ct_retry = agent._get_transport() _cnr_retry = _ct_retry.normalize_response(retry_response) final_response = (_cnr_retry.content or "").strip() elif agent.api_mode == "anthropic_messages": _tretry = agent._get_transport() + _retry_overrides = effective_fast_mode_overrides(agent) _ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None, is_oauth=agent._is_anthropic_oauth, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + context_length=getattr(agent.context_compressor, "context_length", None), + base_url=getattr(agent, "_anthropic_base_url", None), + fast_mode=_retry_overrides.get("speed") == "fast", + drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False))) retry_response = agent._anthropic_messages_create(_ant_kw2) _retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_retry_result.content or "").strip() @@ -1956,6 +1977,8 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if summary_extra_body: summary_kwargs["extra_body"] = summary_extra_body + summary_kwargs.update(effective_fast_mode_overrides(agent)) + summary_response = agent._ensure_primary_openai_client(reason="iteration_limit_summary_retry").chat.completions.create(**summary_kwargs) _retry_result = agent._get_transport().normalize_response(summary_response) final_response = (_retry_result.content or "").strip() diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 420d12670e62..518d5a3791fc 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -31,6 +31,7 @@ from agent.conversation_compression import conversation_history_after_compression from agent.display import KawaiiSpinner from agent.error_classifier import FailoverReason, classify_api_error +from agent.fast_mode import begin_fast_mode_turn from agent.iteration_budget import IterationBudget from agent.turn_context import build_turn_context from agent.turn_retry_state import TurnRetryState @@ -566,6 +567,12 @@ def run_conversation( Returns: Dict: Complete conversation result with final response and message history """ + # Dynamic fast modes are turn-local. Resolve eligibility and start the + # clock at ingress so prompt assembly, hooks, and preflight work count + # toward the same cutoff as the provider calls they precede. Cold-mode + # eligibility comes from the durable prior transcript. + begin_fast_mode_turn(agent, conversation_history) + if moa_config is None: try: from hermes_cli.moa_config import decode_moa_turn @@ -1349,6 +1356,11 @@ def _stop_spinner(): _use_streaming = False def _perform_api_call(next_api_kwargs): + from agent.fast_mode import revalidate_fast_mode_request + + next_api_kwargs = revalidate_fast_mode_request( + agent, next_api_kwargs + ) if agent.api_mode == "codex_responses": next_api_kwargs = agent._get_transport().preflight_kwargs( next_api_kwargs, diff --git a/agent/fast_mode.py b/agent/fast_mode.py new file mode 100644 index 000000000000..81da4c819f0a --- /dev/null +++ b/agent/fast_mode.py @@ -0,0 +1,168 @@ +"""Turn-local fast-mode policy. + +Hermes stores the user's fast-mode preference on ``agent.service_tier`` for +backward compatibility with the existing Normal/Fast implementation. The +``auto`` and ``cold`` values are Hermes policies, not API service tiers: +``auto`` opens the configured fast window on every user turn, while ``cold`` +opens it only on the first turn of a logical session. + +The policy only returns an ephemeral copy of ``request_overrides``. It never +mutates the conversation, system prompt, tool schemas, or the agent's persisted +override map, preserving prompt-cache stability across the tool loop. +""" + +from __future__ import annotations + +import math +import time +from typing import Any + + +DEFAULT_FAST_AUTO_ON_SECONDS = 60.0 + + +def normalize_fast_auto_on_seconds(value: Any) -> float: + """Return a positive finite cutoff, falling back to the 60-second default.""" + try: + cutoff = float(value) + except (TypeError, ValueError): + return DEFAULT_FAST_AUTO_ON_SECONDS + if isinstance(value, bool) or not math.isfinite(cutoff) or cutoff <= 0: + return DEFAULT_FAST_AUTO_ON_SECONDS + return cutoff + + +def _has_prior_session_activity(history: Any) -> bool: + """Return whether a transcript already contains a conversational turn. + + System-only history is setup for a new session, not evidence of a prior + user turn. Any user, assistant, or tool row is conservatively treated as + existing session activity, including compressed or partially recovered + transcripts. + """ + if not isinstance(history, (list, tuple)): + return False + return any( + isinstance(message, dict) + and message.get("role") in {"user", "assistant", "tool"} + for message in history + ) + + +def begin_fast_mode_turn( + agent: Any, + conversation_history: Any = None, + *, + now: float | None = None, +) -> None: + """Resolve fast-window eligibility at one user-turn boundary. + + ``cold`` derives its state from durable transcript history so recreating an + agent process for an existing session does not open another fast window. + The live message list is a fallback for callers that omit explicit history. + """ + mode = getattr(agent, "service_tier", None) + agent._fast_mode_turn_mode = mode + eligible = mode == "auto" + if mode == "cold": + history = conversation_history + if history is None: + history = getattr(agent, "_session_messages", None) + eligible = not _has_prior_session_activity(history) + + agent._fast_mode_turn_eligible = eligible + agent._fast_mode_turn_started_at = ( + (time.monotonic() if now is None else now) if eligible else None + ) + + +def invalidate_fast_mode_turn(agent: Any) -> None: + """Prevent a live policy change from reusing another mode's turn clock.""" + agent._fast_mode_turn_mode = None + agent._fast_mode_turn_eligible = False + agent._fast_mode_turn_started_at = None + + +def effective_request_overrides( + agent: Any, *, now: float | None = None +) -> dict[str, Any]: + """Resolve request overrides for the model call starting now. + + Explicit Normal/Fast behavior is unchanged. Dynamic modes remove any stale + fast-only key from the copied override map, then re-add the active model's + provider-specific fast override while the current turn is eligible and + inside the cutoff. + """ + overrides = dict(getattr(agent, "request_overrides", {}) or {}) + mode = getattr(agent, "service_tier", None) + if mode not in {"auto", "cold"}: + return overrides + + overrides.pop("service_tier", None) + overrides.pop("speed", None) + + turn_mode = getattr(agent, "_fast_mode_turn_mode", None) + if turn_mode is not None and turn_mode != mode: + return overrides + + current = time.monotonic() if now is None else now + started_at = getattr(agent, "_fast_mode_turn_started_at", None) + if not isinstance(started_at, (int, float)): + if mode == "cold" or getattr(agent, "_fast_mode_turn_eligible", None) is False: + return overrides + started_at = current + agent._fast_mode_turn_eligible = True + agent._fast_mode_turn_started_at = started_at + + cutoff = normalize_fast_auto_on_seconds( + getattr(agent, "fast_auto_on_seconds", DEFAULT_FAST_AUTO_ON_SECONDS) + ) + elapsed = max(0.0, current - float(started_at)) + if elapsed <= cutoff: + from hermes_cli.models import resolve_fast_mode_overrides + + fast_overrides = resolve_fast_mode_overrides(getattr(agent, "model", None)) + if fast_overrides: + overrides.update(fast_overrides) + return overrides + + +def effective_fast_mode_overrides( + agent: Any, *, now: float | None = None +) -> dict[str, Any]: + """Return only provider fast-tier keys from the effective request policy.""" + effective = effective_request_overrides(agent, now=now) + return { + key: effective[key] + for key in ("service_tier", "speed") + if key in effective + } + + +def revalidate_fast_mode_request(agent: Any, api_kwargs: dict[str, Any]) -> dict[str, Any]: + """Re-resolve a dynamic fast policy immediately before provider dispatch.""" + mode = getattr(agent, "service_tier", None) + if mode not in {"auto", "cold"}: + return api_kwargs + + kwargs = dict(api_kwargs) + kwargs.pop("service_tier", None) + kwargs.pop("speed", None) + overrides = effective_fast_mode_overrides(agent) + + if getattr(agent, "api_mode", None) == "anthropic_messages": + from agent.anthropic_adapter import _apply_fast_mode_to_kwargs + + return _apply_fast_mode_to_kwargs( + kwargs, + enabled=overrides.get("speed") == "fast", + model=getattr(agent, "model", "") or "", + base_url=getattr(agent, "_anthropic_base_url", None), + is_oauth=bool(getattr(agent, "_is_anthropic_oauth", False)), + drop_context_1m_beta=bool( + getattr(agent, "_oauth_1m_beta_disabled", False) + ), + ) + + kwargs.update(overrides) + return kwargs diff --git a/cli-config.yaml.example b/cli-config.yaml.example index a040c1a24935..2ffb9d590be6 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -749,6 +749,13 @@ agent: # Controls how much "thinking" the model does before responding. # Options: "xhigh" (max), "high", "medium", "low", "minimal", "none" (disable) reasoning_effort: "medium" + + # Fast-mode policy used by `/fast`: "normal", "fast", "auto", or "cold". + # Auto accelerates the opening window of every user turn. Cold accelerates + # only the opening window of the first turn in a logical session, including + # across process restarts. Later tool-loop/retry requests use normal mode. + # service_tier: "normal" + # fast_auto_on_seconds: 60 # Per-model reasoning effort overrides (optional dict) # Key: any sensible model spelling works (exact, dots↔dashes interchangeable, diff --git a/cli.py b/cli.py index 8184f98d392b..e15e4a8ab9a6 100644 --- a/cli.py +++ b/cli.py @@ -348,12 +348,14 @@ def _parse_reasoning_config(effort) -> dict | None: def _parse_service_tier_config(raw: str) -> str | None: - """Parse a persisted service-tier preference into a Responses API value.""" + """Parse a persisted fast-mode preference into its internal policy value.""" value = str(raw or "").strip().lower() if not value or value in {"normal", "default", "standard", "off", "none"}: return None if value in {"fast", "priority", "on"}: return "priority" + if value in {"auto", "cold"}: + return value logger.warning("Unknown service_tier '%s', ignoring", raw) return None @@ -428,6 +430,7 @@ def load_cli_config() -> Dict[str, Any]: "prefill_messages_file": "", "reasoning_effort": "", "service_tier": "", + "fast_auto_on_seconds": 60, "personalities": { "helpful": "You are a helpful, friendly AI assistant.", "concise": "You are a concise assistant. Keep responses brief and to the point.", @@ -3904,6 +3907,10 @@ def __init__( self.service_tier = _parse_service_tier_config( CLI_CONFIG["agent"].get("service_tier", "") ) + from agent.fast_mode import normalize_fast_auto_on_seconds + self.fast_auto_on_seconds = normalize_fast_auto_on_seconds( + CLI_CONFIG["agent"].get("fast_auto_on_seconds", 60) + ) # OpenRouter provider routing preferences pr = CLI_CONFIG.get("provider_routing", {}) or {} diff --git a/gateway/run.py b/gateway/run.py index e5ca409706ad..4af369fe3e61 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2935,6 +2935,7 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._ephemeral_system_prompt = self._load_ephemeral_system_prompt() self._reasoning_config = self._load_reasoning_config() self._service_tier = self._load_service_tier() + self._fast_auto_on_seconds = self._load_fast_auto_on_seconds() self._show_reasoning = self._load_show_reasoning() self._busy_input_mode = self._load_busy_input_mode() self._busy_text_mode = self._load_busy_text_mode() @@ -4006,10 +4007,10 @@ def _resolve_session_agent_runtime( def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict: """Build the effective model/runtime config for a single turn. - Always uses the session's primary model/provider. If `/fast` is - enabled and the model supports Priority Processing / Anthropic fast - mode, attach `request_overrides` so the API call is marked - accordingly. + Always uses the session's primary model/provider. Persistent fast mode + resolves its provider override here. Auto mode deliberately leaves the + route override empty so the turn-local policy can resolve each request + at the API boundary. """ from hermes_cli.models import resolve_fast_mode_overrides @@ -4037,7 +4038,7 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar } service_tier = getattr(self, "_service_tier", None) - if not service_tier: + if not service_tier or service_tier in {"auto", "cold"}: route["request_overrides"] = {} return route @@ -5019,7 +5020,8 @@ def _load_service_tier() -> str | None: """Load Priority Processing setting from config.yaml. Reads agent.service_tier from config.yaml. Accepted values mirror the CLI: - "fast"/"priority"/"on" => "priority", while "normal"/"off" disables it. + "fast"/"priority"/"on" => "priority"; "auto" and "cold" enable + turn-local policies, while "normal"/"off" disables fast mode. Returns None when unset or unsupported. """ cfg = _load_gateway_runtime_config() @@ -5030,9 +5032,21 @@ def _load_service_tier() -> str | None: return None if value in {"fast", "priority", "on"}: return "priority" + if value in {"auto", "cold"}: + return value logger.warning("Unknown service_tier '%s', ignoring", raw) return None + @staticmethod + def _load_fast_auto_on_seconds() -> float: + """Load and normalize the per-turn auto-fast cutoff.""" + from agent.fast_mode import normalize_fast_auto_on_seconds + + cfg = _load_gateway_runtime_config() + return normalize_fast_auto_on_seconds( + cfg_get(cfg, "agent", "fast_auto_on_seconds", default=60) + ) + @staticmethod def _load_show_reasoning() -> bool: """Load show_reasoning toggle from config.yaml display section.""" @@ -13841,6 +13855,7 @@ async def _run_background_task_inner( ) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() + self._fast_auto_on_seconds = self._load_fast_auto_on_seconds() turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) # Enrich the prompt with image descriptions so the background @@ -13871,6 +13886,7 @@ def run_sync(): disabled_toolsets=disabled_toolsets, reasoning_config=reasoning_config, service_tier=self._service_tier, + fast_auto_on_seconds=self._fast_auto_on_seconds, request_overrides=turn_route.get("request_overrides"), providers_allowed=pr.get("only"), providers_ignored=pr.get("ignore"), @@ -18705,6 +18721,7 @@ def run_sync(): ) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() + self._fast_auto_on_seconds = self._load_fast_auto_on_seconds() # Set up stream consumer for token streaming or interim commentary. _stream_consumer = None _stream_delta_cb = None @@ -19046,6 +19063,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: prefill_messages=self._prefill_messages or None, reasoning_config=reasoning_config, service_tier=self._service_tier, + fast_auto_on_seconds=self._fast_auto_on_seconds, request_overrides=turn_route.get("request_overrides"), providers_allowed=pr.get("only"), providers_ignored=pr.get("ignore"), @@ -19136,6 +19154,7 @@ def _notice_callback_sync(notice) -> None: agent.event_callback = _event_callback_sync agent.reasoning_config = reasoning_config agent.service_tier = self._service_tier + agent.fast_auto_on_seconds = self._fast_auto_on_seconds agent.request_overrides = turn_route.get("request_overrides") or {} _bg_review_release = threading.Event() diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 9223689bd32a..fec86ca06488 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3067,6 +3067,14 @@ def _apply_fast_selection(value: str) -> str: self._service_tier = "priority" saved_value = "fast" label = t("gateway.fast.label_fast") + elif value == "auto": + self._service_tier = "auto" + saved_value = "auto" + label = t("gateway.fast.label_auto") + elif value == "cold": + self._service_tier = "cold" + saved_value = "cold" + label = t("gateway.fast.label_cold") elif value in {"normal", "off"}: self._service_tier = None saved_value = "normal" @@ -3078,10 +3086,17 @@ def _apply_fast_selection(value: str) -> str: return t("gateway.fast.session_only", label=label) if not args or args == "status": - is_fast = self._service_tier == "priority" - status = t("gateway.fast.status_fast") if is_fast else t("gateway.fast.status_normal") + if self._service_tier == "auto": + status = t("gateway.fast.status_auto") + elif self._service_tier == "cold": + status = t("gateway.fast.status_cold") + else: + status = t("gateway.fast.status_fast") if self._service_tier == "priority" else t("gateway.fast.status_normal") - # Interactive picker on platforms that support it. + # Interactive picker on platforms that support it. The picker offers the + # fast/normal quick-toggle; the bounded auto and cold policies remain + # available as typed arguments (`/fast auto`, `/fast cold`) and are + # reflected in the status line above. session_key = self._session_key_for_source(event.source) async def _on_fast_choice(_chat_id: str, value: str) -> str: @@ -3095,12 +3110,12 @@ async def _on_fast_choice(_chat_id: str, value: str) -> str: { "value": "fast", "label": t("gateway.fast.choice_fast"), - "is_current": is_fast, + "is_current": self._service_tier == "priority", }, { "value": "normal", "label": t("gateway.fast.choice_normal"), - "is_current": not is_fast, + "is_current": self._service_tier is None, }, ], on_choice_selected=_on_fast_choice, diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index b0a9e9e2fa10..22a2c5cdd5c4 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -182,10 +182,10 @@ def _ensure_runtime_credentials(self) -> bool: def _resolve_turn_agent_config(self, user_message: str) -> dict: """Build the effective model/runtime config for a single user turn. - Always uses the session's primary model/provider. If the user has - toggled `/fast` on and the current model supports Priority - Processing / Anthropic fast mode, attach `request_overrides` so the - API call is marked accordingly. + Always uses the session's primary model/provider. Persistent fast mode + resolves its provider override here. Auto mode deliberately leaves the + route override empty so the turn-local policy can resolve each request + at the API boundary. """ from hermes_cli.models import resolve_fast_mode_overrides @@ -212,7 +212,7 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: } service_tier = getattr(self, "service_tier", None) - if not service_tier: + if not service_tier or service_tier in {"auto", "cold"}: route["request_overrides"] = None return route @@ -370,6 +370,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No prefill_messages=self.prefill_messages or None, reasoning_config=self.reasoning_config, service_tier=self.service_tier, + fast_auto_on_seconds=getattr(self, "fast_auto_on_seconds", 60.0), request_overrides=request_overrides, providers_allowed=self._providers_only, providers_ignored=self._providers_ignore, diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index c8bf1e67136c..60d3e0e23550 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1651,6 +1651,7 @@ def run_background(): session_db=self._session_db, reasoning_config=self.reasoning_config, service_tier=self.service_tier, + fast_auto_on_seconds=getattr(self, "fast_auto_on_seconds", 60.0), request_overrides=turn_route.get("request_overrides"), providers_allowed=self._providers_only, providers_ignored=self._providers_ignore, @@ -2607,9 +2608,13 @@ def _handle_fast_command(self, cmd: str): parts = cmd.strip().split(maxsplit=1) if len(parts) < 2 or parts[1].strip().lower() == "status": - status = "fast" if self.service_tier == "priority" else "normal" + status = ( + self.service_tier if self.service_tier in {"auto", "cold"} + else "fast" if self.service_tier == "priority" + else "normal" + ) _cprint(f" {_ACCENT}{feature_name}: {status}{_RST}") - _cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}") + _cprint(f" {_DIM}Usage: /fast [normal|fast|auto|cold|status]{_RST}") return arg = parts[1].strip().lower() @@ -2618,13 +2623,21 @@ def _handle_fast_command(self, cmd: str): self.service_tier = "priority" saved_value = "fast" label = "FAST" + elif arg == "auto": + self.service_tier = "auto" + saved_value = "auto" + label = "AUTO" + elif arg == "cold": + self.service_tier = "cold" + saved_value = "cold" + label = "COLD" elif arg in {"normal", "off"}: self.service_tier = None saved_value = "normal" label = "NORMAL" else: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}") + _cprint(f" {_DIM}Usage: /fast [normal|fast|auto|cold|status]{_RST}") return self.agent = None # Force agent re-init with new service-tier config diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 10f8fbf046b8..aa22a5d48c51 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -155,9 +155,9 @@ class CommandDef: CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide|full|clamp]", subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp")), - CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration", - args_hint="[normal|fast|status]", - subcommands=("normal", "fast", "status", "on", "off")), + CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast/Auto/Cold)", "Configuration", + args_hint="[normal|fast|auto|cold|status]", + subcommands=("normal", "fast", "auto", "cold", "status", "on", "off")), CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"), CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 060373555f0b..785443137c03 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1021,6 +1021,7 @@ def _ensure_hermes_home_managed(home: Path): # provider hiccups on a single provider. "api_max_retries": 3, "service_tier": "", + "fast_auto_on_seconds": 60, # Tool-use enforcement: injects system prompt guidance that tells the # model to actually call tools instead of describing intended actions. # Values: "auto" (default — applies to gpt/codex models), true/false diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a50f615d60a1..7f5de12182d0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -718,8 +718,12 @@ def _memory_provider_options() -> List[str]: }, "agent.service_tier": { "type": "select", - "description": "API service tier (OpenAI/Anthropic)", - "options": ["", "auto", "default", "flex"], + "description": "Fast-mode policy (OpenAI Priority / Anthropic Fast)", + "options": ["normal", "fast", "auto", "cold"], + }, + "agent.fast_auto_on_seconds": { + "type": "number", + "description": "Seconds that auto/cold fast windows stay active", }, "delegation.reasoning_effort": { "type": "select", diff --git a/locales/af.yaml b/locales/af.yaml index 6ade25b452e7..9940b8d828b8 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (gestoor in konfigurasie)\n_(neem effek by die volgende boodskap)_" session_only: "⚡ ✓ Priority Processing: **{label}** (slegs hierdie sessie)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nHuidige modus: `{mode}`\\n\\nKies \\'n opsie:" choice_fast: "fast — Priority Processing aan" diff --git a/locales/de.yaml b/locales/de.yaml index 2c6b83d3359a..466e1e7b3f0f 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (in Konfiguration gespeichert)\n_(wird ab nächster Nachricht wirksam)_" session_only: "⚡ ✓ Priority Processing: **{label}** (nur diese Sitzung)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nAktueller Modus: `{mode}`\\n\\nOption wählen:" choice_fast: "fast — Priority Processing an" diff --git a/locales/en.yaml b/locales/en.yaml index 2bd229e2b52c..ed8fcb452776 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -130,14 +130,18 @@ gateway: denied_reason_plural: "❌ Commands denied ({count} commands). Reason relayed to the agent: \"{reason}\"" fast: - not_supported: "⚡ /fast is only available for OpenAI models that support Priority Processing." - status: "⚡ Priority Processing\n\nCurrent mode: `{mode}`\n\n_Usage:_ `/fast `" - unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid options:** normal, fast, status" + not_supported: "⚡ /fast is only available for models that support OpenAI Priority Processing or Anthropic Fast Mode." + status: "⚡ Priority Processing\n\nCurrent mode: `{mode}`\n\n_Usage:_ `/fast `" + unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid options:** normal, fast, auto, cold, status" saved: "⚡ ✓ Priority Processing: **{label}** (saved to config)\n_(takes effect on next message)_" session_only: "⚡ ✓ Priority Processing: **{label}** (this session only)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\n\nCurrent mode: `{mode}`\n\nPick an option:" choice_fast: "fast — Priority Processing on" diff --git a/locales/es.yaml b/locales/es.yaml index 61e2b4418c04..282b924db128 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (guardado en la configuración)\n_(se aplica en el próximo mensaje)_" session_only: "⚡ ✓ Priority Processing: **{label}** (solo esta sesión)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nModo actual: `{mode}`\\n\\nElige una opción:" choice_fast: "fast — Priority Processing activado" diff --git a/locales/fr.yaml b/locales/fr.yaml index d067b26eeeae..f66362a2e491 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing : **{label}** (enregistré dans la configuration)\n_(prend effet au prochain message)_" session_only: "⚡ ✓ Priority Processing : **{label}** (cette session uniquement)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nMode actuel : `{mode}`\\n\\nChoisissez une option :" choice_fast: "fast — Priority Processing activé" diff --git a/locales/ga.yaml b/locales/ga.yaml index 32ed8d597add..d403a771332d 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -125,8 +125,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (sábháilte sa chumraíocht)\n_(éifeachtach ón gcéad teachtaireacht eile)_" session_only: "⚡ ✓ Priority Processing: **{label}** (an seisiún seo amháin)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nMód reatha: `{mode}`\\n\\nRoghnaigh rogha:" choice_fast: "fast — Priority Processing ar siúl" diff --git a/locales/hu.yaml b/locales/hu.yaml index 97a2e675155f..d9605f1a08cd 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (mentve a konfigurációba)\n_(a következő üzenettől lép életbe)_" session_only: "⚡ ✓ Priority Processing: **{label}** (csak ebben a munkamenetben)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nJelenlegi mód: `{mode}`\\n\\nVálassz egy opciót:" choice_fast: "fast — Priority Processing bekapcsolva" diff --git a/locales/it.yaml b/locales/it.yaml index f390f2879c69..9a6068d73a4d 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (salvato nella configurazione)\n_(verrà applicato al prossimo messaggio)_" session_only: "⚡ ✓ Priority Processing: **{label}** (solo per questa sessione)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nModalità attuale: `{mode}`\\n\\nScegli un\\'opzione:" choice_fast: "fast — Priority Processing attivo" diff --git a/locales/ja.yaml b/locales/ja.yaml index 2682dc586356..fe819aa26ea9 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (設定に保存しました)\n_(次のメッセージから有効)_" session_only: "⚡ ✓ Priority Processing: **{label}** (このセッションのみ)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\n現在のモード: `{mode}`\\n\\nオプションを選択:" choice_fast: "fast — Priority Processing オン" diff --git a/locales/ko.yaml b/locales/ko.yaml index df07aa12a380..1951a729c262 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (설정에 저장됨)\n_(다음 메시지부터 적용됩니다)_" session_only: "⚡ ✓ Priority Processing: **{label}** (이 세션에만 적용)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\n현재 모드: `{mode}`\\n\\n옵션을 선택하세요:" choice_fast: "fast — Priority Processing 켜기" diff --git a/locales/pt.yaml b/locales/pt.yaml index 3559c044c549..8c526ac6f3fa 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (guardado na configuração)\n_(produz efeito na próxima mensagem)_" session_only: "⚡ ✓ Priority Processing: **{label}** (apenas esta sessão)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nModo atual: `{mode}`\\n\\nEscolha uma opção:" choice_fast: "fast — Priority Processing ativado" diff --git a/locales/ru.yaml b/locales/ru.yaml index b55c006244bb..37d71b6f9cd7 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (сохранено в конфигурации)\n_(вступит в силу со следующего сообщения)_" session_only: "⚡ ✓ Priority Processing: **{label}** (только этот сеанс)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nТекущий режим: `{mode}`\\n\\nВыберите вариант:" choice_fast: "fast — Priority Processing включён" diff --git a/locales/tr.yaml b/locales/tr.yaml index 5e5f0a151755..150d61364b8e 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (yapılandırmaya kaydedildi)\n_(sonraki mesajda geçerli olur)_" session_only: "⚡ ✓ Priority Processing: **{label}** (yalnızca bu oturum)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nMevcut mod: `{mode}`\\n\\nBir seçenek seçin:" choice_fast: "fast — Priority Processing açık" diff --git a/locales/uk.yaml b/locales/uk.yaml index 4606ebe91a5f..33f1213ebbcb 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing: **{label}** (збережено в конфігурації)\n_(набуде чинності з наступного повідомлення)_" session_only: "⚡ ✓ Priority Processing: **{label}** (лише ця сесія)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\nПоточний режим: `{mode}`\\n\\nОберіть варіант:" choice_fast: "fast — Priority Processing увімкнено" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 39b7079a7e3d..c63dd8785462 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ Priority Processing:**{label}**(已儲存到設定)\n_(下一則訊息生效)_" session_only: "⚡ ✓ Priority Processing:**{label}**(僅本次工作階段)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **Priority Processing**\\n\\n目前模式:`{mode}`\\n\\n請選擇:" choice_fast: "fast — 開啟 Priority Processing" diff --git a/locales/zh.yaml b/locales/zh.yaml index 7cf13776a678..5a6ead73de03 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -121,8 +121,12 @@ gateway: saved: "⚡ ✓ 优先处理:**{label}**(已保存到配置)\n_(下一条消息生效)_" session_only: "⚡ ✓ 优先处理:**{label}**(仅本次会话)" label_fast: "FAST" + label_auto: "AUTO" + label_cold: "COLD" label_normal: "NORMAL" status_fast: "fast" + status_auto: "auto" + status_cold: "cold" status_normal: "normal" picker_title: "⚡ **优先处理**\\n\\n当前模式:`{mode}`\\n\\n请选择:" choice_fast: "fast — 开启优先处理" diff --git a/run_agent.py b/run_agent.py index 4d0436247e56..28b5de8e7d2b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -488,6 +488,7 @@ def __init__( checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, + fast_auto_on_seconds: float = 60.0, ): """Forwarder — see ``agent.agent_init.init_agent``.""" from agent.agent_init import init_agent @@ -540,6 +541,7 @@ def __init__( max_tokens=max_tokens, reasoning_config=reasoning_config, service_tier=service_tier, + fast_auto_on_seconds=fast_auto_on_seconds, request_overrides=request_overrides, prefill_messages=prefill_messages, platform=platform, diff --git a/tests/agent/test_fast_mode_auto.py b/tests/agent/test_fast_mode_auto.py new file mode 100644 index 000000000000..d7c572a739a0 --- /dev/null +++ b/tests/agent/test_fast_mode_auto.py @@ -0,0 +1,274 @@ +from types import SimpleNamespace + +import pytest + +from agent.fast_mode import ( + DEFAULT_FAST_AUTO_ON_SECONDS, + begin_fast_mode_turn, + effective_request_overrides, + invalidate_fast_mode_turn, + normalize_fast_auto_on_seconds, + revalidate_fast_mode_request, +) + + +def _agent(model="gpt-5.4", **overrides): + values = { + "model": model, + "service_tier": "auto", + "fast_auto_on_seconds": 60, + "request_overrides": {"unrelated": "preserved"}, + } + values.update(overrides) + return SimpleNamespace(**values) + + +@pytest.mark.parametrize("value", [None, "", 0, -1, float("inf"), True]) +def test_invalid_auto_cutoff_uses_default(value): + assert normalize_fast_auto_on_seconds(value) == DEFAULT_FAST_AUTO_ON_SECONDS + + +def test_auto_fast_is_active_through_cutoff_then_removed(): + agent = _agent(request_overrides={"service_tier": "priority", "unrelated": 1}) + begin_fast_mode_turn(agent, now=100.0) + + assert effective_request_overrides(agent, now=160.0) == { + "service_tier": "priority", + "unrelated": 1, + } + assert effective_request_overrides(agent, now=160.001) == {"unrelated": 1} + + +def test_auto_fast_resets_for_each_user_turn(): + agent = _agent() + begin_fast_mode_turn(agent, now=100.0) + assert "service_tier" not in effective_request_overrides(agent, now=161.0) + + begin_fast_mode_turn(agent, now=200.0) + assert effective_request_overrides(agent, now=200.0)["service_tier"] == "priority" + + +def test_cold_fast_only_opens_on_first_logical_session_turn(): + agent = _agent(service_tier="cold", _session_messages=[]) + + begin_fast_mode_turn(agent, [], now=100.0) + assert effective_request_overrides(agent, now=100.0)["service_tier"] == "priority" + + agent._session_messages = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "reply"}, + ] + begin_fast_mode_turn(agent, None, now=200.0) + assert effective_request_overrides(agent, now=200.0) == { + "unrelated": "preserved" + } + + +def test_cold_fast_explicit_empty_history_starts_new_logical_session(): + agent = _agent( + service_tier="cold", + _session_messages=[ + {"role": "user", "content": "previous session"}, + {"role": "assistant", "content": "previous reply"}, + ], + ) + + begin_fast_mode_turn(agent, [], now=100.0) + + assert effective_request_overrides(agent, now=100.0)["service_tier"] == "priority" + + +def test_cold_fast_stays_off_when_fresh_process_resumes_persisted_history(): + agent = _agent(service_tier="cold", _session_messages=[]) + persisted_history = [ + {"role": "user", "content": "before restart"}, + {"role": "assistant", "content": "persisted reply"}, + ] + + begin_fast_mode_turn(agent, persisted_history, now=100.0) + + assert agent._fast_mode_turn_started_at is None + assert effective_request_overrides(agent, now=100.0) == { + "unrelated": "preserved" + } + + +def test_cold_fast_treats_system_only_history_as_first_turn(): + agent = _agent(service_tier="cold", _session_messages=[]) + + begin_fast_mode_turn( + agent, [{"role": "system", "content": "session setup"}], now=100.0 + ) + + assert effective_request_overrides(agent, now=160.0)["service_tier"] == "priority" + assert effective_request_overrides(agent, now=160.001) == { + "unrelated": "preserved" + } + + +@pytest.mark.parametrize("role", ["assistant", "tool"]) +def test_cold_fast_conservatively_rejects_partial_prior_transcripts(role): + agent = _agent(service_tier="cold", _session_messages=[]) + + begin_fast_mode_turn(agent, [{"role": role, "content": "prior"}], now=100.0) + + assert effective_request_overrides(agent, now=100.0) == { + "unrelated": "preserved" + } + + +def test_cold_fast_does_not_lazy_start_without_a_turn_boundary(): + agent = _agent(service_tier="cold") + + assert effective_request_overrides(agent, now=100.0) == { + "unrelated": "preserved" + } + + +def test_live_dynamic_mode_change_cannot_reuse_prior_turn_clock(): + agent = _agent(service_tier="auto", _session_messages=[]) + begin_fast_mode_turn(agent, [], now=100.0) + + agent.service_tier = "cold" + + assert effective_request_overrides(agent, now=110.0) == { + "unrelated": "preserved" + } + + +def test_invalidated_dynamic_mode_waits_for_next_turn_boundary(): + agent = _agent(service_tier="auto", _session_messages=[]) + begin_fast_mode_turn(agent, [], now=100.0) + invalidate_fast_mode_turn(agent) + + assert effective_request_overrides(agent, now=110.0) == { + "unrelated": "preserved" + } + + +def test_dispatch_revalidation_strips_openai_fast_after_cutoff(monkeypatch): + agent = _agent( + service_tier="auto", + api_mode="chat_completions", + _session_messages=[], + ) + begin_fast_mode_turn(agent, [], now=100.0) + built_kwargs = {"model": "gpt-5.4", "service_tier": "priority"} + monkeypatch.setattr("agent.fast_mode.time.monotonic", lambda: 160.001) + + dispatched = revalidate_fast_mode_request(agent, built_kwargs) + + assert "service_tier" not in dispatched + + +def test_dispatch_revalidation_preserves_middleware_override(monkeypatch): + agent = _agent( + service_tier="auto", + api_mode="chat_completions", + request_overrides={"timeout": 999, "service_tier": "priority"}, + _session_messages=[], + ) + begin_fast_mode_turn(agent, [], now=100.0) + middleware_kwargs = { + "model": "gpt-5.4", + "timeout": 5, + "service_tier": "priority", + } + monkeypatch.setattr("agent.fast_mode.time.monotonic", lambda: 110.0) + + dispatched = revalidate_fast_mode_request(agent, middleware_kwargs) + + assert dispatched["timeout"] == 5 + assert dispatched["service_tier"] == "priority" + + +def test_dispatch_revalidation_strips_anthropic_fast_metadata_after_cutoff( + monkeypatch, +): + agent = _agent( + model="anthropic/claude-opus-4.6", + service_tier="auto", + api_mode="anthropic_messages", + _anthropic_base_url="https://api.anthropic.com", + _is_anthropic_oauth=False, + _oauth_1m_beta_disabled=False, + _session_messages=[], + ) + begin_fast_mode_turn(agent, [], now=100.0) + built_kwargs = { + "model": "claude-opus-4-6", + "extra_body": {"speed": "fast", "unrelated": 1}, + "extra_headers": { + "anthropic-beta": "interleaved-thinking-2025-05-14,fast-mode-2026-02-01", + "x-extra": "preserved", + }, + } + monkeypatch.setattr("agent.fast_mode.time.monotonic", lambda: 160.001) + + dispatched = revalidate_fast_mode_request(agent, built_kwargs) + + assert dispatched["extra_body"] == {"unrelated": 1} + assert dispatched["extra_headers"] == { + "anthropic-beta": "interleaved-thinking-2025-05-14", + "x-extra": "preserved", + } + + +def test_dispatch_revalidation_preserves_middleware_anthropic_beta(monkeypatch): + agent = _agent( + model="anthropic/claude-opus-4.6", + service_tier="auto", + api_mode="anthropic_messages", + _anthropic_base_url="https://api.anthropic.com", + _is_anthropic_oauth=False, + _oauth_1m_beta_disabled=False, + _session_messages=[], + ) + begin_fast_mode_turn(agent, [], now=100.0) + middleware_kwargs = { + "model": "claude-opus-4-6", + "extra_headers": { + "anthropic-beta": "plugin-feature-2099-01-01", + "x-plugin": "preserved", + }, + } + monkeypatch.setattr("agent.fast_mode.time.monotonic", lambda: 110.0) + + dispatched = revalidate_fast_mode_request(agent, middleware_kwargs) + + betas = dispatched["extra_headers"]["anthropic-beta"].split(",") + assert "plugin-feature-2099-01-01" in betas + assert "fast-mode-2026-02-01" in betas + assert dispatched["extra_headers"]["x-plugin"] == "preserved" + + +def test_auto_fast_uses_anthropic_speed_override(): + agent = _agent(model="anthropic/claude-opus-4.6") + begin_fast_mode_turn(agent, now=10.0) + + assert effective_request_overrides(agent, now=20.0) == { + "speed": "fast", + "unrelated": "preserved", + } + assert effective_request_overrides(agent, now=71.0) == {"unrelated": "preserved"} + + +def test_explicit_fast_mode_is_unchanged(): + agent = _agent( + service_tier="priority", + request_overrides={"service_tier": "priority", "unrelated": 1}, + ) + begin_fast_mode_turn(agent, now=100.0) + + assert agent._fast_mode_turn_started_at is None + assert effective_request_overrides(agent, now=1000.0) == { + "service_tier": "priority", + "unrelated": 1, + } + + +def test_unsupported_model_never_adds_a_fast_override(): + agent = _agent(model="openrouter/some-unsupported-model") + begin_fast_mode_turn(agent, now=10.0) + + assert effective_request_overrides(agent, now=10.0) == {"unrelated": "preserved"} diff --git a/tests/cli/test_fast_command.py b/tests/cli/test_fast_command.py index 7745737c4541..3c1440cc9dcb 100644 --- a/tests/cli/test_fast_command.py +++ b/tests/cli/test_fast_command.py @@ -29,6 +29,12 @@ def test_fast_maps_to_priority(self): self.assertEqual(self._parse("fast"), "priority") self.assertEqual(self._parse("priority"), "priority") + def test_auto_is_preserved_as_policy(self): + self.assertEqual(self._parse("auto"), "auto") + + def test_cold_is_preserved_as_policy(self): + self.assertEqual(self._parse("cold"), "cold") + def test_normal_disables_service_tier(self): self.assertIsNone(self._parse("normal")) self.assertIsNone(self._parse("off")) @@ -87,6 +93,32 @@ def test_normal_argument_clears_service_tier(self): self.assertIsNone(stub.service_tier) self.assertIsNone(stub.agent) + def test_auto_argument_persists_policy(self): + cli_mod = _import_cli() + stub = self._make_cli(service_tier=None) + with ( + patch.object(cli_mod, "_cprint"), + patch.object(cli_mod, "save_config_value", return_value=True) as mock_save, + ): + cli_mod.HermesCLI._handle_fast_command(stub, "/fast auto") + + mock_save.assert_called_once_with("agent.service_tier", "auto") + self.assertEqual(stub.service_tier, "auto") + self.assertIsNone(stub.agent) + + def test_cold_argument_persists_policy(self): + cli_mod = _import_cli() + stub = self._make_cli(service_tier=None) + with ( + patch.object(cli_mod, "_cprint"), + patch.object(cli_mod, "save_config_value", return_value=True) as mock_save, + ): + cli_mod.HermesCLI._handle_fast_command(stub, "/fast cold") + + mock_save.assert_called_once_with("agent.service_tier", "cold") + self.assertEqual(stub.service_tier, "cold") + self.assertIsNone(stub.agent) + def test_unsupported_model_does_not_expose_fast(self): cli_mod = _import_cli() stub = SimpleNamespace( @@ -258,6 +290,24 @@ def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self): assert route["runtime"]["provider"] == "openrouter" assert route.get("request_overrides") is None + def test_turn_route_leaves_cold_policy_for_request_time_resolution(self): + cli_mod = _import_cli() + stub = SimpleNamespace( + model="gpt-5.4", + api_key="primary-key", + base_url="https://openrouter.ai/api/v1", + provider="openrouter", + api_mode="chat_completions", + acp_command=None, + acp_args=[], + _credential_pool=None, + service_tier="cold", + ) + + route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") + + assert route.get("request_overrides") is None + class TestAnthropicFastMode(unittest.TestCase): """Verify Anthropic Fast Mode model support and override resolution.""" @@ -483,3 +533,4 @@ def test_default_config_has_service_tier(self): agent = DEFAULT_CONFIG.get("agent", {}) self.assertIn("service_tier", agent) self.assertEqual(agent["service_tier"], "") + self.assertEqual(agent["fast_auto_on_seconds"], 60) diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index a5b07c89880d..ebdc2987b2e4 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -59,6 +59,7 @@ def _make_runner(): runner._prefill_messages = [] runner._reasoning_config = None runner._service_tier = None + runner._fast_auto_on_seconds = 60.0 runner._provider_routing = {} runner._fallback_model = None runner._running_agents = {} @@ -142,6 +143,46 @@ def test_turn_route_skips_priority_processing_for_unsupported_models(): assert route["request_overrides"] == {} +def test_turn_route_leaves_auto_policy_for_request_time_resolution(): + runner = _make_runner() + runner._service_tier = "auto" + runtime_kwargs = { + "api_key": "***", + "base_url": "https://openrouter.ai/api/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "command": None, + "args": [], + "credential_pool": None, + } + + route = gateway_run.GatewayRunner._resolve_turn_agent_config( + runner, "hi", "gpt-5.4", runtime_kwargs + ) + + assert route["request_overrides"] == {} + + +def test_turn_route_leaves_cold_policy_for_request_time_resolution(): + runner = _make_runner() + runner._service_tier = "cold" + runtime_kwargs = { + "api_key": "***", + "base_url": "https://openrouter.ai/api/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + "command": None, + "args": [], + "credential_pool": None, + } + + route = gateway_run.GatewayRunner._resolve_turn_agent_config( + runner, "hi", "gpt-5.4", runtime_kwargs + ) + + assert route["request_overrides"] == {} + + @pytest.mark.asyncio async def test_handle_fast_command_persists_config(monkeypatch, tmp_path): runner = _make_runner() @@ -159,6 +200,40 @@ async def test_handle_fast_command_persists_config(monkeypatch, tmp_path): assert saved["agent"]["service_tier"] == "fast" +@pytest.mark.asyncio +async def test_handle_fast_command_persists_auto_policy(monkeypatch, tmp_path): + runner = _make_runner() + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + + response = await runner._handle_fast_command(_make_event("/fast auto")) + + assert "AUTO" in response + assert runner._service_tier == "auto" + saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert saved["agent"]["service_tier"] == "auto" + + +@pytest.mark.asyncio +async def test_handle_fast_command_persists_cold_policy(monkeypatch, tmp_path): + runner = _make_runner() + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr( + gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4" + ) + + response = await runner._handle_fast_command(_make_event("/fast cold")) + + assert "COLD" in response + assert runner._service_tier == "cold" + saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert saved["agent"]["service_tier"] == "cold" + + @pytest.mark.asyncio async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch, tmp_path): _install_fake_agent(monkeypatch) diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index 56dbd153ef08..120cc55f6750 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -535,6 +535,49 @@ def test_includes_service_tier_via_request_overrides(self, monkeypatch): kwargs = agent._build_api_kwargs(messages) assert kwargs["service_tier"] == "priority" + def test_auto_service_tier_expires_at_request_boundary(self, monkeypatch): + agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses", + base_url="https://chatgpt.com/backend-api/codex") + agent.model = "gpt-5.4" + agent.service_tier = "auto" + agent.fast_auto_on_seconds = 60 + agent._fast_mode_turn_started_at = 100.0 + messages = [{"role": "user", "content": "hi"}] + + monkeypatch.setattr("agent.fast_mode.time.monotonic", lambda: 160.0) + active_kwargs = agent._build_api_kwargs(messages) + assert active_kwargs["service_tier"] == "priority" + + monkeypatch.setattr("agent.fast_mode.time.monotonic", lambda: 160.001) + expired_kwargs = agent._build_api_kwargs(messages) + assert "service_tier" not in expired_kwargs + assert agent.request_overrides == {} + + def test_cold_service_tier_respects_turn_eligibility(self, monkeypatch): + agent = _make_agent( + monkeypatch, + "openai-codex", + api_mode="codex_responses", + base_url="https://chatgpt.com/backend-api/codex", + ) + agent.model = "gpt-5.4" + agent.service_tier = "cold" + agent.fast_auto_on_seconds = 60 + messages = [{"role": "user", "content": "hi"}] + + from agent.fast_mode import begin_fast_mode_turn + + begin_fast_mode_turn(agent, [], now=100.0) + monkeypatch.setattr("agent.fast_mode.time.monotonic", lambda: 110.0) + assert agent._build_api_kwargs(messages)["service_tier"] == "priority" + + begin_fast_mode_turn( + agent, + [{"role": "user", "content": "prior"}], + now=200.0, + ) + assert "service_tier" not in agent._build_api_kwargs(messages) + def test_omits_max_output_tokens_for_codex_backend(self, monkeypatch): agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses", base_url="https://chatgpt.com/backend-api/codex") diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index c218e75564ee..aeada0573c5f 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3836,6 +3836,57 @@ def test_returns_summary(self, agent): assert len(result) > 0 assert "summary" in result.lower() + def test_openai_summary_uses_active_dynamic_fast_policy(self, agent): + from agent.fast_mode import begin_fast_mode_turn + + agent.model = "gpt-5.4" + agent.service_tier = "auto" + agent.fast_auto_on_seconds = 60 + begin_fast_mode_turn(agent, [], now=100.0) + agent.client.chat.completions.create.return_value = _mock_response( + content="Summary" + ) + + with patch("agent.fast_mode.time.monotonic", return_value=110.0): + result = agent._handle_max_iterations( + [{"role": "user", "content": "do stuff"}], 1 + ) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + assert kwargs["service_tier"] == "priority" + + def test_anthropic_summary_uses_active_dynamic_fast_policy(self, agent): + from agent.fast_mode import begin_fast_mode_turn + + agent.api_mode = "anthropic_messages" + agent.provider = "anthropic" + agent.model = "claude-opus-4-6" + agent.service_tier = "cold" + agent.fast_auto_on_seconds = 60 + agent._anthropic_base_url = "https://api.anthropic.com" + agent._is_anthropic_oauth = False + begin_fast_mode_turn(agent, [], now=100.0) + + transport = MagicMock() + transport.build_kwargs.return_value = { + "model": agent.model, + "extra_body": {"speed": "fast"}, + } + transport.normalize_response.return_value = SimpleNamespace(content="Summary") + + with ( + patch.object(agent, "_get_transport", return_value=transport), + patch.object(agent, "_anthropic_messages_create", return_value=object()), + patch("agent.fast_mode.time.monotonic", return_value=110.0), + ): + result = agent._handle_max_iterations( + [{"role": "user", "content": "do stuff"}], 1 + ) + + assert result == "Summary" + assert transport.build_kwargs.call_args.kwargs["fast_mode"] is True + def test_api_failure_returns_error(self, agent): agent.client.chat.completions.create.side_effect = Exception("API down") agent._cached_system_prompt = "You are helpful." @@ -4073,6 +4124,80 @@ def fake_run_codex_stream(kwargs): for item in input_items ) + @pytest.mark.parametrize( + ("clock_values", "response_texts", "expired_dispatch_index"), + [ + ([110.0, 160.001], ["Summary"], 0), + ([110.0, 110.0, 110.0, 160.001], ["", "Retry summary"], 1), + ], + ids=["primary", "retry"], + ) + def test_codex_summary_revalidates_fast_cutoff_before_dispatch( + self, + agent, + clock_values, + response_texts, + expired_dispatch_index, + ): + from agent.fast_mode import begin_fast_mode_turn + + agent.api_mode = "codex_responses" + agent.provider = "openai-codex" + agent.base_url = "https://chatgpt.com/backend-api/codex" + agent._base_url_lower = agent.base_url.lower() + agent._base_url_hostname = "chatgpt.com" + agent.model = "gpt-5.5" + agent.service_tier = "auto" + agent.fast_auto_on_seconds = 60 + agent._cached_system_prompt = "You are helpful." + begin_fast_mode_turn(agent, [], now=100.0) + + built_kwargs = [] + dispatched_kwargs = [] + original_build = agent._build_api_kwargs + + def capture_build(messages): + kwargs = original_build(messages) + built_kwargs.append(dict(kwargs)) + return kwargs + + pending_responses = iter(response_texts) + + def capture_dispatch(kwargs): + dispatched_kwargs.append(dict(kwargs)) + return SimpleNamespace( + status="completed", + output=[ + SimpleNamespace( + type="message", + status="completed", + content=[ + SimpleNamespace( + type="output_text", text=next(pending_responses) + ) + ], + ) + ], + ) + + with ( + patch.object(agent, "_build_api_kwargs", side_effect=capture_build), + patch.object(agent, "_run_codex_stream", side_effect=capture_dispatch), + patch( + "agent.fast_mode.time.monotonic", + side_effect=clock_values, + ), + ): + result = agent._handle_max_iterations( + [{"role": "user", "content": "do stuff"}], 1 + ) + + assert result == response_texts[-1] + assert built_kwargs[expired_dispatch_index]["service_tier"] == "priority" + assert "service_tier" not in dispatched_kwargs[expired_dispatch_index] + for kwargs in dispatched_kwargs[:expired_dispatch_index]: + assert kwargs["service_tier"] == "priority" + def test_api_sanitizer_matches_responses_call_id_when_id_differs(self, agent): messages = [ { diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index adaeeefb93db..55929a39bc12 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2160,6 +2160,23 @@ def test_background_agent_kwargs_preserves_full_fallback_chain(monkeypatch): assert kwargs["fallback_model"] == chain +def test_background_and_preview_agents_inherit_fast_cutoff(monkeypatch): + agent = types.SimpleNamespace( + model="gpt-5.5", + provider="openai", + fast_auto_on_seconds=7.5, + ) + monkeypatch.setattr(server, "_load_cfg", lambda: {"max_turns": 25}) + monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"]) + monkeypatch.setattr(server, "_get_db", lambda: None) + + background = server._background_agent_kwargs(agent, "background-id") + preview = server._ephemeral_preview_agent_kwargs(agent, "preview-id") + + assert background["fast_auto_on_seconds"] == 7.5 + assert preview["fast_auto_on_seconds"] == 7.5 + + def test_background_agent_kwargs_preserves_empty_fallback_chain(monkeypatch): agent = types.SimpleNamespace( model="gpt-5.5", @@ -3975,6 +3992,112 @@ def test_config_set_fast_status_is_non_mutating(monkeypatch): server._sessions.pop("sid", None) +def test_config_set_fast_auto_updates_live_agent_without_static_override(monkeypatch): + writes = [] + agent = types.SimpleNamespace( + model="openai/gpt-5.4", + request_overrides={"foo": "bar", "service_tier": "priority"}, + service_tier="priority", + _fast_mode_turn_mode="auto", + _fast_mode_turn_eligible=True, + _fast_mode_turn_started_at=100.0, + ) + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) + monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"}) + monkeypatch.setattr(server, "_emit", lambda *args: None) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "auto"}, + } + ) + assert resp["result"]["value"] == "auto" + assert agent.service_tier == "auto" + assert agent.request_overrides == {"foo": "bar"} + assert ("agent.service_tier", "auto") in writes + + status = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "status"}, + } + ) + assert status["result"]["value"] == "auto" + finally: + server._sessions.pop("sid", None) + + +def test_config_set_fast_cold_updates_live_agent_without_static_override(monkeypatch): + writes = [] + agent = types.SimpleNamespace( + model="openai/gpt-5.4", + request_overrides={"foo": "bar", "service_tier": "priority"}, + service_tier="auto", + _fast_mode_turn_mode="auto", + _fast_mode_turn_eligible=True, + _fast_mode_turn_started_at=100.0, + ) + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) + monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"}) + monkeypatch.setattr(server, "_emit", lambda *args: None) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "cold"}, + } + ) + assert resp["result"]["value"] == "cold" + assert agent.service_tier == "cold" + assert agent.request_overrides == {"foo": "bar"} + assert agent._fast_mode_turn_mode is None + assert agent._fast_mode_turn_eligible is False + assert agent._fast_mode_turn_started_at is None + assert ("agent.service_tier", "cold") in writes + + status = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "status"}, + } + ) + assert status["result"]["value"] == "cold" + finally: + server._sessions.pop("sid", None) + + +def test_config_get_fast_reports_live_cold_policy(): + agent = types.SimpleNamespace(service_tier="cold") + server._sessions["sid"] = _session(agent=agent) + + try: + response = server.handle_request( + { + "id": "1", + "method": "config.get", + "params": {"session_id": "sid", "key": "fast"}, + } + ) + assert response["result"]["value"] == "cold" + finally: + server._sessions.pop("sid", None) + + def test_config_set_fast_rejects_unsupported_model(monkeypatch): writes = [] agent = types.SimpleNamespace( @@ -8328,14 +8451,21 @@ def _opener(_url, timeout=2.0): # noqa: ARG001 — match urllib signature import urllib.request monkeypatch.setattr(urllib.request, "urlopen", _opener) + launch_result = types.SimpleNamespace(launched=True, hint=None) with patch.dict(sys.modules, {"tools.browser_tool": fake}): with patch( - "hermes_cli.browser_connect.try_launch_chrome_debug", return_value=True - ): + "hermes_cli.browser_connect.subprocess.Popen", + side_effect=AssertionError("test must never launch a real browser"), + ) as popen_mock, patch( + "hermes_cli.browser_connect.launch_chrome_debug", + return_value=launch_result, + ) as launch_mock: resp = server.handle_request( {"id": "1", "method": "browser.manage", "params": {"action": "connect"}} ) + launch_mock.assert_called_once() + popen_mock.assert_not_called() assert resp["result"]["connected"] is True assert resp["result"]["url"] == "http://127.0.0.1:9222" assert resp["result"]["messages"] == [ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2f92833d33b3..136644a34071 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2826,9 +2826,19 @@ def _load_service_tier() -> str | None: return None if raw in {"fast", "priority", "on"}: return "priority" + if raw in {"auto", "cold"}: + return raw return None +def _load_fast_auto_on_seconds() -> float: + from agent.fast_mode import normalize_fast_auto_on_seconds + + return normalize_fast_auto_on_seconds( + (_load_cfg().get("agent") or {}).get("fast_auto_on_seconds", 60) + ) + + def _load_provider_routing() -> dict: """OpenRouter provider-routing prefs from config.yaml (``provider_routing``). @@ -4446,6 +4456,11 @@ def _agent_fallback_model(agent): def _background_agent_kwargs(agent, task_id: str) -> dict: cfg = _load_cfg() + from agent.fast_mode import normalize_fast_auto_on_seconds + + parent_fast_cutoff = getattr(agent, "fast_auto_on_seconds", None) + if parent_fast_cutoff is None: + parent_fast_cutoff = _load_fast_auto_on_seconds() return { "base_url": getattr(agent, "base_url", None) or None, @@ -4475,6 +4490,7 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: "reasoning_config": getattr(agent, "reasoning_config", None) or _load_reasoning_config(str(getattr(agent, "model", "") or "")), "service_tier": getattr(agent, "service_tier", None) or _load_service_tier(), + "fast_auto_on_seconds": normalize_fast_auto_on_seconds(parent_fast_cutoff), "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), "platform": "tui", "session_db": _get_db(), @@ -4947,6 +4963,7 @@ def _make_agent( if service_tier_override is not None else _load_service_tier() ), + fast_auto_on_seconds=_load_fast_auto_on_seconds(), enabled_toolsets=_load_enabled_toolsets(), # OpenRouter provider-routing prefs (config.yaml `provider_routing`). # Mirrors the messaging gateway + CLI so the desktop/TUI honors the same @@ -10744,28 +10761,38 @@ def _(rid, params: dict) -> dict: if key == "fast": raw = str(value or "").strip().lower() agent = session.get("agent") if session else None - if agent is not None: - current_fast = getattr(agent, "service_tier", None) == "priority" - else: - current_fast = _load_service_tier() == "priority" + current_tier = ( + getattr(agent, "service_tier", None) + if agent is not None + else _load_service_tier() + ) + current_mode = ( + current_tier if current_tier in {"auto", "cold"} + else "fast" if current_tier == "priority" + else "normal" + ) if raw in {"status"}: return _ok( rid, - {"key": key, "value": "fast" if current_fast else "normal"}, + {"key": key, "value": current_mode}, ) if raw in {"", "toggle"}: - nv = "normal" if current_fast else "fast" + nv = "normal" if current_mode in {"fast", "auto", "cold"} else "fast" elif raw in {"fast", "on"}: nv = "fast" + elif raw == "auto": + nv = "auto" + elif raw == "cold": + nv = "cold" elif raw in {"normal", "off"}: nv = "normal" else: return _err(rid, 4002, f"unknown fast mode: {value}") overrides = None - if nv == "fast": + if nv in {"fast", "auto", "cold"}: from hermes_cli.models import resolve_fast_mode_overrides target_model = ( @@ -10787,7 +10814,12 @@ def _(rid, params: dict) -> dict: _write_config_key("agent.service_tier", nv) if agent is not None: - agent.service_tier = "priority" if nv == "fast" else None + from agent.fast_mode import invalidate_fast_mode_turn + + invalidate_fast_mode_turn(agent) + agent.service_tier = ( + "priority" if nv == "fast" else nv if nv in {"auto", "cold"} else None + ) current_overrides = dict(getattr(agent, "request_overrides", {}) or {}) current_overrides.pop("service_tier", None) current_overrides.pop("speed", None) @@ -11751,15 +11783,20 @@ def _(rid, params: dict) -> dict: ) return _ok(rid, {"value": effort, "display": display}) if key == "fast": + session = _sessions.get(params.get("session_id", "")) + current_tier = ( + getattr(session.get("agent"), "service_tier", None) + if session + else _load_service_tier() + ) return _ok( rid, { "value": ( - "fast" - if (session := _sessions.get(params.get("session_id", ""))) - and getattr(session.get("agent"), "service_tier", None) - == "priority" - else ("fast" if _load_service_tier() == "priority" else "normal") + current_tier + if current_tier in {"auto", "cold"} + else "fast" if current_tier == "priority" + else "normal" ), }, ) @@ -13889,9 +13926,20 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: elif name == "fast" and agent: mode = arg.lower() if mode in {"fast", "on"}: - agent.service_tier = "priority" + new_tier = "priority" + elif mode == "auto": + new_tier = "auto" + elif mode == "cold": + new_tier = "cold" elif mode in {"normal", "off"}: - agent.service_tier = None + new_tier = None + else: + new_tier = getattr(agent, "service_tier", None) + if mode in {"fast", "on", "auto", "cold", "normal", "off"}: + from agent.fast_mode import invalidate_fast_mode_turn + + invalidate_fast_mode_turn(agent) + agent.service_tier = new_tier _emit("session.info", sid, _session_info(agent, session)) elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): agent.reload_mcp_tools() diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 8b5786de5a55..7d72f79c98ad 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -71,7 +71,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) for OpenAI/Codex models. `auto` (default) uses Hermes' standard chat completions; `codex_app_server` hands turns to a `codex app-server` subprocess for native shell, apply_patch, ChatGPT subscription auth, and migrated Codex plugins. Effective on next session. | | `/personality` | Set a predefined personality | | `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. | -| `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. Options: `normal`, `fast`, `status`. | +| `/fast [normal\|fast\|auto\|cold\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. `auto` accelerates the opening window of every user turn; `cold` accelerates only the first turn of a logical session, including across process restarts. The window defaults to 60 seconds (`agent.fast_auto_on_seconds`). | | `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) | | `/skin` | Show or change the display skin/theme | | `/statusbar` (alias: `/sb`) | Toggle the context/model status bar on or off | @@ -211,7 +211,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/model [provider:model]` | Show or change the model. Supports provider switches (`/model zai:glm-5`), custom endpoints (`/model custom:model`), named custom providers (`/model custom:local:qwen`), auto-detect (`/model custom`), and user-defined aliases (`/model fav`, `/model grok` — see [Custom model aliases](#custom-model-aliases)). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider or set up API keys, use `hermes model` from your terminal (outside the chat session). **Cost note:** a mid-session model switch resets the prompt cache (the cache key includes the model), so the next message re-reads the whole conversation at full input price. | | `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime). Persists to `model.openai_runtime` in config.yaml and evicts the cached agent so the next message picks up the new runtime. Effective on next session. | | `/personality [name]` | Set a personality overlay for the session. | -| `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. | +| `/fast [normal\|fast\|auto\|cold\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode; `auto` applies to each turn's opening window, while `cold` applies only to the first turn of a logical session. | | `/retry` | Retry the last message. | | `/undo` | Remove the last exchange. | | `/sethome` (alias: `/set-home`) | Mark the current chat as the platform home channel for deliveries. |