* 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.
69 lines
2.5 KiB
Python
69 lines
2.5 KiB
Python
import pytest
|
|
|
|
from gateway.session_context import _UNSET, _VAR_MAP, clear_session_vars, set_session_vars
|
|
from agent.session_source import session_source_for
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_contextvars():
|
|
for var in _VAR_MAP.values():
|
|
var.set(_UNSET)
|
|
yield
|
|
for var in _VAR_MAP.values():
|
|
var.set(_UNSET)
|
|
|
|
|
|
def test_session_source_context_overrides_platform(monkeypatch):
|
|
monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False)
|
|
|
|
tokens = set_session_vars(source="tool")
|
|
try:
|
|
assert session_source_for("tui") == "tool"
|
|
finally:
|
|
clear_session_vars(tokens)
|
|
|
|
|
|
def test_session_source_falls_back_to_platform(monkeypatch):
|
|
monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False)
|
|
|
|
assert session_source_for("tui") == "tui"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("inherited", ["", "tui", "desktop"])
|
|
def test_oneshot_run_gets_distinct_source(monkeypatch, inherited):
|
|
"""A finite `hermes chat -q` / `hermes -z` run is tagged `oneshot`, whether launched from a plain shell
|
|
or spawned inside a TUI/Desktop session (whose transport label it inherits but is not) (#112550)."""
|
|
monkeypatch.setenv("HERMES_SESSION_SOURCE", inherited)
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
|
|
assert session_source_for("cli") == "oneshot"
|
|
|
|
|
|
def test_oneshot_marker_does_not_relabel_subagents(monkeypatch):
|
|
"""Delegate children inside a one-shot process share its env but keep their own platform."""
|
|
monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False)
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
|
|
assert session_source_for("subagent") == "subagent"
|
|
|
|
|
|
@pytest.mark.parametrize("inherited", ["kanban", "tool", "a2a"])
|
|
def test_oneshot_child_keeps_inherited_automation_source(monkeypatch, inherited):
|
|
monkeypatch.setenv("HERMES_SESSION_SOURCE", inherited)
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
|
|
assert session_source_for("cli") == inherited
|
|
|
|
|
|
@pytest.mark.parametrize("explicit", ["tui", "desktop"])
|
|
def test_oneshot_keeps_explicit_source_flag(monkeypatch, explicit):
|
|
"""`hermes chat -q --source tui` is a documented flag, not an inherited transport label: main.py
|
|
marks it HERMES_SESSION_SOURCE_EXPLICIT=1 and the one-shot drop must not override it."""
|
|
monkeypatch.setenv("HERMES_SESSION_SOURCE", explicit)
|
|
monkeypatch.setenv("HERMES_SESSION_SOURCE_EXPLICIT", "1")
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
|
|
assert session_source_for("cli") == explicit
|