* fix(agent): free-tier rate limits end an attended turn at once A free-tier (no sign-in) session that hit the service-wide rate limit slept up to 600 s, retried into the same refusal, slept again, and then sent its third attempt on a guest JWT that had expired during the wait. The welcome host answered 403 "invalid jwt", which read as the tier being switched off. The user watched a spinner for about 18 minutes and then saw the wrong card. - An attended session (desktop, TUI, the interactive CLI, ACP) on the free model ends the attempt cycle at once when the cooldown is longer than 60 s: fallback first, else the reset time and the sign-in / model doors. "Attended" is keyed on the session source, so `hermes chat -q`, cron and messaging sessions keep the long wait. The source vocabulary moves from run_agent.py into agent/session_source.py, which adds is_attended(). - on_free_model() (welcome host + anonymous credential) is the one free-tier check, used by the cutoff, the "free model is busy" wait copy and the outage copy. - A welcome-host 403 "invalid jwt" is its own route kind, session_expired: the existing one-shot route heal re-exchanges the credential and retries; if that fails the card is the retryable outage card. - interruptible_backoff_sleep adopts a fresh Nous key after each wait. _adopt_nous_key_before_expiry owns its refresh failure, so both call sites drop their try/except. - A retry wait over 60 s is announced in the transcript when it starts, for every provider; shorter waits stay buffered. Every wait line names the attempt that runs after it. - max_retries_exhausted_result's max_retries becomes attempts (what it carries); a one-attempt rate-limit end gets its own lead. - One Retry-After reader, retry_utils.provider_retry_after_seconds, shared by compute_error_backoff and the recovery ladder. - _session_default_route replaces _session_default_model everywhere in tui_gateway, so a not-yet-built free-tier session reports nous/welcome on create, reuse, resume, branch and row persist, not the silent default. * fix(agent): free-tier cutoff no longer ends subagent or library turns, paid waits look like main (review) A delegated child of a TUI/Desktop session and a library caller (no platform) are unattended, so they keep waiting. Rate-limit wait lines keep main's attempt numbers and stay buffered for every provider; only a non-rate-limit Retry-After over 60 s is announced, as on main. The unattended free-tier wait line keeps the reset time beside "The free model is busy". The free-tier cutoff names the reset time for any cooldown it fires on (over 60 s), not only from 120 s. session.info for an env-seeded model off the free tier carries the model alone again, as on main. Behaviour tests for the cutoff, the subagent and unattended waits, and a paid long wait.
45 lines
2.3 KiB
Python
45 lines
2.3 KiB
Python
"""Session source vocabulary: the ``source`` label a session row carries, and which sources have a
|
|
person reading the turn as it runs. A leaf (its one import is the stdlib-only session context), so
|
|
the ``run_agent`` facade and its siblings import it directly."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
from gateway.session_context import get_session_env
|
|
|
|
# Sources that label the human conversation an interactive UI transport hosts. A finite ``hermes chat -q`` /
|
|
# one-shot child spawned from such a session inherits HERMES_SESSION_SOURCE (the terminal tool bridges the
|
|
# session env into child processes) but is NOT that conversation: labelling it ``tui``/``desktop`` lists it
|
|
# in the TUI/WebUI pickers as a resumable chat and lets ``hermes -c`` in the TUI continue it (#112550).
|
|
# Automation sources (kanban, tool, cron, a2a, ...) are inherited on purpose.
|
|
UI_TRANSPORT_SOURCES = frozenset({"tui", "desktop"})
|
|
|
|
# Finite non-interactive CLI runs (``hermes chat -q``/``--oneshot``, ``hermes -z``) get their own source so human
|
|
# pickers hide them without title/cwd heuristics; ``hermes -c`` still treats them as CLI history.
|
|
ONESHOT_SOURCE = "oneshot"
|
|
CLI_FAMILY_SOURCES = frozenset({"cli", ONESHOT_SOURCE})
|
|
|
|
# A person reads the turn as it runs: not cron, batch, a messaging chat, or a one-shot run.
|
|
ATTENDED_SOURCES = UI_TRANSPORT_SOURCES | {"cli", "acp"}
|
|
|
|
|
|
def session_source_for(platform: Optional[str]) -> str:
|
|
source = str(get_session_env("HERMES_SESSION_SOURCE", "") or "").strip()
|
|
single_query = get_session_env("HERMES_SINGLE_QUERY_SESSION", "") == "1"
|
|
explicit = get_session_env("HERMES_SESSION_SOURCE_EXPLICIT", "") == "1"
|
|
if single_query and not explicit and source in UI_TRANSPORT_SOURCES:
|
|
source = ""
|
|
if single_query and not source and (platform or "cli") == "cli":
|
|
return ONESHOT_SOURCE
|
|
return source or platform or "cli"
|
|
|
|
|
|
def is_attended(agent: Any) -> bool:
|
|
"""A delegated child answers its parent, not a person, though its copied context still carries
|
|
the parent's source; a library caller (no platform) has no one reading either."""
|
|
platform = getattr(agent, "platform", None)
|
|
if not platform or platform == "subagent":
|
|
return False
|
|
return session_source_for(platform) in ATTENDED_SOURCES
|