Skip to content
Merged
35 changes: 7 additions & 28 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
STATUS_EXHAUSTED, credential_pool_matches_provider, resolve_runtime_pool_key
)
from agent.error_classifier import FailoverReason
from agent.retry_utils import parse_retry_after_seconds, reset_delay_from_message
from agent.turn_context import drop_stale_api_content
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -3104,34 +3105,12 @@ def cleanup_dead_connections(agent) -> bool:
return False


_QUOTA_RESET_DELAY_RE = re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE)
_RESETS_IN_RE = re.compile(
r"resets?\s+in\s+"
r"(?:(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b\s*)?"
r"(?:(\d+(?:\.\d+)?)\s*(?:m|min|mins|minute|minutes)\b\s*)?"
r"(?:(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b)?", re.IGNORECASE,
)
_RETRY_AFTER_SECONDS_RE = re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", re.IGNORECASE)


def _reset_delay_from_message(message: str) -> Optional[float]:
"""Seconds-until-reset parsed from free-text provider messages, or None."""
m = _QUOTA_RESET_DELAY_RE.search(message)
if m:
value = float(m.group(1))
return value / 1000.0 if m.group(2).lower() == "ms" else value
m = _RESETS_IN_RE.search(message)
if m and any(m.groups()):
return float(m.group(1) or 0) * 3600 + float(m.group(2) or 0) * 60 + float(m.group(3) or 0)
m = _RETRY_AFTER_SECONDS_RE.search(message)
return float(m.group(1)) if m else None


def _set_reset_from_retry_after(context: Dict[str, Any], retry_after: Any) -> None:
if retry_after in {None, ""} or "reset_at" in context:
if "reset_at" in context:
return
with contextlib.suppress(TypeError, ValueError):
context["reset_at"] = time.time() + float(retry_after)
seconds = parse_retry_after_seconds(retry_after)
if seconds is not None:
context["reset_at"] = time.time() + seconds


def extract_api_error_context(error: Exception) -> Dict[str, Any]:
Expand All @@ -3155,14 +3134,14 @@ def extract_api_error_context(error: Exception) -> Dict[str, Any]:
_set_reset_from_retry_after(context, payload.get("retry_after"))
headers = getattr(getattr(error, "response", None), "headers", None)
if headers:
_set_reset_from_retry_after(context, headers.get("retry-after") or headers.get("Retry-After") or None)
_set_reset_from_retry_after(context, headers)
ratelimit_reset = headers.get("x-ratelimit-reset")
if ratelimit_reset and "reset_at" not in context:
context["reset_at"] = ratelimit_reset
if "message" not in context and str(error).strip():
context["message"] = str(error).strip()[:500]
if "reset_at" not in context and isinstance(context.get("message") or "", str):
delay = _reset_delay_from_message(context.get("message") or "")
delay = reset_delay_from_message(context.get("message") or "")
if delay is not None:
context["reset_at"] = time.time() + delay
return context
Expand Down
6 changes: 4 additions & 2 deletions agent/context_breakdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@


def _chars_to_tokens(text: str) -> int:
return (len(text) + 3) // 4
from agent.model_metadata import estimate_tokens_rough
return estimate_tokens_rough(text)


def _json_tokens(value: Any) -> int:
return _chars_to_tokens(json.dumps(value, ensure_ascii=False)) if value else 0


def _bytes_to_tokens(size: Optional[int]) -> Optional[int]:
return None if size is None else (int(size) + 3) // 4
from agent.model_metadata import CHARS_PER_TOKEN
return None if size is None else (int(size) + 3) // CHARS_PER_TOKEN


def _skills_block(stable: str) -> str:
Expand Down
4 changes: 2 additions & 2 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from agent.micro_compaction import MicroCompactionMixin
from agent.prompt_builder import STEER_DISPLAY_KIND
from agent.model_metadata import (
MINIMUM_CONTEXT_LENGTH, get_model_context_length, estimate_messages_tokens_rough, estimate_tokens_rough,
CHARS_PER_TOKEN, MINIMUM_CONTEXT_LENGTH, get_model_context_length, estimate_messages_tokens_rough, estimate_tokens_rough,
strip_opaque_replay_items,
)
from agent.redact import redact_sensitive_text
Expand Down Expand Up @@ -955,7 +955,7 @@ def _collect_protected_skill_names(messages: List[Dict[str, Any]], prune_boundar
}


_CHARS_PER_TOKEN = 4
_CHARS_PER_TOKEN = CHARS_PER_TOKEN
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600

# Fallback handoff preserves continuity anchors only, not a transcript copy.
Expand Down
33 changes: 2 additions & 31 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.config import load_env
from agent.secret_scope import get_secret as _get_secret
from agent.retry_utils import reset_delay_from_message
from agent.credential_persistence import (
fingerprint_secret_value,
is_borrowed_credential_source,
Expand Down Expand Up @@ -367,36 +368,6 @@ def _parse_absolute_timestamp(value: Any) -> Optional[float]:
return None


# (regex, seconds-from-match) pairs tried in order against provider error text.
_RETRY_DELAY_PATTERNS: Tuple[Tuple[re.Pattern, Callable[[re.Match], float]], ...] = (
(
re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE),
lambda m: float(m.group(1)) / 1000.0 if m.group(2).lower() == "ms" else float(m.group(1)),
),
(
re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", re.IGNORECASE),
lambda m: float(m.group(1)),
),
# "Resets in 4hr 5min" format used by OpenCode Go weekly usage limits
(
re.compile(r"resets?\s+in\s+(\d+)\s*hr\s+(\d+)\s*min", re.IGNORECASE),
lambda m: int(m.group(1)) * 3600 + int(m.group(2)) * 60,
),
(re.compile(r"resets?\s+in\s+(\d+)\s*hr\b", re.IGNORECASE), lambda m: int(m.group(1)) * 3600),
(re.compile(r"resets?\s+in\s+(\d+)\s*min\b", re.IGNORECASE), lambda m: int(m.group(1)) * 60),
)


def _extract_retry_delay_seconds(message: str) -> Optional[float]:
if not message:
return None
for pattern, to_seconds in _RETRY_DELAY_PATTERNS:
match = pattern.search(message)
if match:
return to_seconds(match)
return None


def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(error_context, dict):
return {}
Expand All @@ -413,7 +384,7 @@ def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[st
parsed_reset_at = _parse_absolute_timestamp(reset_at)
message = error_context.get("message")
if parsed_reset_at is None and isinstance(message, str):
retry_delay_seconds = _extract_retry_delay_seconds(message)
retry_delay_seconds = reset_delay_from_message(message)
if retry_delay_seconds is not None:
parsed_reset_at = time.time() + retry_delay_seconds
if parsed_reset_at is not None:
Expand Down
6 changes: 2 additions & 4 deletions agent/gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import httpx

from agent.bounded_response import read_streaming_error_body
from agent.retry_utils import parse_retry_after_seconds
from agent.gemini_schema import sanitize_gemini_tool_parameters

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -608,10 +609,7 @@ def gemini_http_error(response: httpx.Response, *, body_text: Optional[str] = No
err_obj = _error_object(body_text)
err_status, err_message = (str(err_obj.get(k) or "").strip() for k in ("status", "message"))
reason, metadata = _error_info(err_obj)
try:
retry_after: Optional[float] = float(response.headers.get("Retry-After") or response.headers.get("retry-after"))
except (TypeError, ValueError):
retry_after = None
retry_after = parse_retry_after_seconds(response.headers)
message = (
f"Gemini HTTP {status} ({err_status or 'error'}): {err_message}" if err_message
else f"Gemini returned HTTP {status}: {body_text[:500]}"
Expand Down
8 changes: 6 additions & 2 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,10 @@ async def get_model_context_length_async(model: str, base_url: str = "", api_key

# CJK/Hangul/Kana codepoints (~1 token each), counted in one C-level regex pass: Hangul
# Jamo (+Ext-A), CJK radicals/ideographs (+compat), Hangul syllables, fullwidth/halfwidth.
# Rough chars-per-token ratio for ASCII text; the single source for every "N tokens ≈ N*4 chars"
# budget conversion (context files, tool-output budgets, whisper prompt cap, compressor metadata).
CHARS_PER_TOKEN = 4

_CJK_DENSE_RE = re.compile("[\u1100-\u11ff\u2e80-\u9fff\ua960-\ua97f\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]")


Expand All @@ -2058,10 +2062,10 @@ def estimate_tokens_rough(text: str) -> int:
return 0
text = str(text)
if text.isascii(): # flag check on CPython; ASCII cannot contain token-dense CJK
return (len(text) + 3) // 4
return (len(text) + 3) // CHARS_PER_TOKEN
stripped = _CJK_DENSE_RE.sub("", text)
dense = len(text) - len(stripped)
return dense + ((len(stripped.encode("utf-8", "replace")) + 3) // 4)
return dense + ((len(stripped.encode("utf-8", "replace")) + 3) // CHARS_PER_TOKEN)


def estimate_messages_tokens_rough(messages: List[Dict[str, Any]], *, charge_stale_thinking: bool = True) -> int:
Expand Down
5 changes: 3 additions & 2 deletions agent/native_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ def native_compaction_context_management(agent: Any, *, is_codex_backend: bool,


def _approx_tokens(text: str) -> int:
"""Cheap chars//4 token estimate — same shape Codex uses for retention."""
return max(1, len(text) // 4)
"""Retention cost of one carried-over text; never 0 so an empty item still consumes budget."""
from agent.model_metadata import estimate_tokens_rough
return max(1, estimate_tokens_rough(text))


def _extract_item_text(item: Any) -> Optional[str]:
Expand Down
6 changes: 4 additions & 2 deletions agent/nous_rate_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import time
from typing import Any, Mapping, Optional
from utils import atomic_write_text
from agent.retry_utils import parse_retry_after_seconds
from agent.rate_limit_tracker import (
_BUCKET_TAGS, _fmt_seconds, _safe_float, _safe_int, has_rate_limit_headers, lower_headers,
)
Expand All @@ -41,11 +42,12 @@ def _state_path() -> str:
def _parse_reset_seconds(headers: Optional[Mapping[str, str]]) -> Optional[float]:
"""Best reset estimate (seconds from now) from hourly, per-minute, then retry-after headers."""
lowered = lower_headers(headers)
for key in ("x-ratelimit-reset-requests-1h", "x-ratelimit-reset-requests", "retry-after"):
for key in ("x-ratelimit-reset-requests-1h", "x-ratelimit-reset-requests"):
val = _safe_float(lowered.get(key), 0.0)
if val > 0:
return val
return None
retry_after = parse_retry_after_seconds(lowered.get("retry-after"))
return retry_after if retry_after else None


def record_nous_rate_limit(
Expand Down
16 changes: 8 additions & 8 deletions agent/process_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
import sys
import threading
import time
import urllib.request
from typing import Any, Optional

from utils import base_url_hostname, normalize_proxy_url
from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy


_OPENAI_CLS_CACHE = None
Expand Down Expand Up @@ -288,18 +288,18 @@ def __getattr__(self, name):

def _get_proxy_from_env() -> Optional[str]:
"""First configured proxy URL from HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (any case), or None."""
keys = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
return next((normalize_proxy_url(v) for k in keys if (v := os.environ.get(k, "").strip())), None)
value = first_proxy_env_value()
return normalize_proxy_url(value) if value else None


def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
"""Env-configured proxy unless NO_PROXY excludes this base URL."""
"""Env-configured proxy unless NO_PROXY excludes this base URL (same matcher as the
gateway adapters: CIDR, ``*.`` wildcards and host:port entries all count)."""
proxy = _get_proxy_from_env()
host = base_url_hostname(base_url) if proxy and base_url else ""
try:
return None if host and urllib.request.proxy_bypass_environment(host) else proxy
except Exception:
if not (proxy and base_url):
return proxy
raw = base_url.strip()
return None if should_bypass_proxy(raw if "://" in raw else f"//{raw}") else proxy


def _shared_transport_cls():
Expand Down
8 changes: 4 additions & 4 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
get_hermes_home, get_skills_dir, is_wsl, reset_hermes_home_override, set_hermes_home_override,
)

from agent.model_metadata import CHARS_PER_TOKEN
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS, ORG_ACTIVE_MARKER, ORG_MIRROR_DIR_NAME, ORG_PROVENANCE_FILE, SKILL_SUPPORT_DIRS,
Expand Down Expand Up @@ -1026,9 +1027,8 @@ def build_environment_hints() -> str:
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2

# Dynamic cap (no explicit context_file_max_chars): ~4 chars/token, a small slice of the window since
# context files share the cached prefix; small models stay at the floor.
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
# Dynamic cap (no explicit context_file_max_chars): a small slice of the window since context files
# share the cached prefix; small models stay at the floor.
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000

Expand All @@ -1037,7 +1037,7 @@ def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
"""Char cap from the model's window, clamped to [20K floor, 500K ceiling]; flat default when unknown."""
if not isinstance(context_length, int) or context_length <= 0:
return CONTEXT_FILE_MAX_CHARS
budget = int(context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION)
budget = int(context_length * CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION)
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))


Expand Down
Loading
Loading