     1|"""Model metadata, context lengths, and token estimation utilities.
     2|
     3|Pure utility functions with no AIAgent dependency. Used by ContextCompressor
     4|and run_agent.py for pre-flight context checks.
     5|"""
     6|
     7|import ipaddress
     8|import logging
     9|import os
    10|import re
    11|import time
    12|from pathlib import Path
    13|from typing import Any, Dict, List, Optional, Tuple
    14|from urllib.parse import urlparse
    15|
    16|import requests
    17|import yaml
    18|
    19|from utils import base_url_host_matches, base_url_hostname
    20|
    21|from hermes_constants import OPENROUTER_MODELS_URL
    22|
    23|logger = logging.getLogger(__name__)
    24|
    25|
    26|def _resolve_requests_verify() -> bool | str:
    27|    """Resolve SSL verify setting for `requests` calls from env vars.
    28|
    29|    The `requests` library only honours REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE
    30|    by default. Hermes also honours HERMES_CA_BUNDLE (its own convention)
    31|    and SSL_CERT_FILE (used by the stdlib `ssl` module and by httpx), so
    32|    that a single env var can cover both `requests` and `httpx` callsites
    33|    inside the same process.
    34|
    35|    Returns either a filesystem path to a CA bundle, or True to defer to
    36|    the requests default (certifi).
    37|    """
    38|    for env_var in ("HERMES_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"):
    39|        val = os.getenv(env_var)
    40|        if val and os.path.isfile(val):
    41|            return val
    42|    return True
    43|
    44|# Provider names that can appear as a "provider:" prefix before a model ID.
    45|# Only these are stripped — Ollama-style "model:tag" colons (e.g. "qwen3.5:27b")
    46|# are preserved so the full model name reaches cache lookups and server queries.
    47|_PROVIDER_PREFIXES: frozenset[str] = frozenset({
    48|    "openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
    49|    "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
    50|    "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita",
    51|    "qwen-oauth",
    52|    "xiaomi",
    53|    "arcee",
    54|    "gmi",
    55|    "tencent-tokenhub",
    56|    "custom", "local",
    57|    # Common aliases
    58|    "google", "google-gemini", "google-ai-studio",
    59|    "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
    60|    "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
    61|    "ollama",
    62|    "stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
    63|    "mimo", "xiaomi-mimo",
    64|    "tencent", "tokenhub", "tencent-cloud", "tencentmaas",
    65|    "arcee-ai", "arceeai",
    66|    "gmi-cloud", "gmicloud",
    67|    "xai", "x-ai", "x.ai", "grok",
    68|    "nvidia", "nim", "nvidia-nim", "nemotron",
    69|    "qwen-portal", "novita-ai", "novitaai",
    70|})
    71|
    72|
    73|_OLLAMA_TAG_PATTERN = re.compile(
    74|    r"^(\d+\.?\d*b|latest|stable|q\d|fp?\d|instruct|chat|coder|vision|text)",
    75|    re.IGNORECASE,
    76|)
    77|
    78|
    79|# Tailscale's CGNAT range (RFC 6598). `ipaddress.is_private` excludes this
    80|# block, so without an explicit check Ollama reached over Tailscale (e.g.
    81|# `http://100.77.243.5:11434`) wouldn't be treated as local and its stream
    82|# read / stale timeouts wouldn't get auto-bumped. Built once at import time.
    83|_TAILSCALE_CGNAT = ipaddress.IPv4Network("100.64.0.0/10")
    84|
    85|
    86|def _strip_provider_prefix(model: str) -> str:
    87|    """Strip a recognised provider prefix from a model string.
    88|
    89|    ``"local:my-model"`` → ``"my-model"``
    90|    ``"qwen3.5:27b"``   → ``"qwen3.5:27b"``  (unchanged — not a provider prefix)
    91|    ``"qwen:0.5b"``     → ``"qwen:0.5b"``    (unchanged — Ollama model:tag)
    92|    ``"deepseek:latest"``→ ``"deepseek:latest"``(unchanged — Ollama model:tag)
    93|    """
    94|    if ":" not in model or model.startswith("http"):
    95|        return model
    96|    prefix, suffix = model.split(":", 1)
    97|    prefix_lower = prefix.strip().lower()
    98|    if prefix_lower in _PROVIDER_PREFIXES:
    99|        # Don't strip if suffix looks like an Ollama tag (e.g. "7b", "latest", "q4_0")
   100|        if _OLLAMA_TAG_PATTERN.match(suffix.strip()):
   101|            return model
   102|        return suffix
   103|    return model
   104|
   105|_model_metadata_cache: Dict[str, Dict[str, Any]] = {}
   106|_model_metadata_cache_time: float = 0
   107|_novita_metadata_cache: Dict[str, Dict[str, Any]] = {}
   108|_novita_metadata_cache_time: float = 0
   109|_MODEL_CACHE_TTL = 3600
   110|_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
   111|_endpoint_model_metadata_cache_time: Dict[str, float] = {}
   112|_ENDPOINT_MODEL_CACHE_TTL = 300
   113|
   114|# Descending tiers for context length probing when the model is unknown.
   115|# We start at 256K (covers GPT-5.x, many current large-context models) and
   116|# step down on context-length errors until one works.  Tier[0] is also the
   117|# default fallback when no detection method succeeds.
   118|CONTEXT_PROBE_TIERS = [
   119|    256_000,
   120|    128_000,
   121|    64_000,
   122|    32_000,
   123|    16_000,
   124|    8_000,
   125|]
   126|
   127|# Default context length when no detection method succeeds.
   128|DEFAULT_FALLBACK_CONTEXT = CONTEXT_PROBE_TIERS[0]
   129|
   130|# Minimum context length required to run Hermes Agent.  Models with fewer
   131|# tokens cannot maintain enough working memory for tool-calling workflows.
   132|# Sessions, model switches, and cron jobs should reject models below this.
   133|MINIMUM_CONTEXT_LENGTH = 64_000
   134|
   135|# Thin fallback defaults — only broad model family patterns.
   136|# These fire only when provider is unknown AND models.dev/OpenRouter/Anthropic
   137|# all miss. Replaced the previous 80+ entry dict.
   138|# For provider-specific context lengths, models.dev is the primary source.
   139|DEFAULT_CONTEXT_LENGTHS = {
   140|    # Anthropic Claude 4.6 (1M context) — bare IDs only to avoid
   141|    # fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
   142|    # substring of "anthropic/claude-sonnet-4.6").
   143|    # OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
   144|    "claude-opus-4-7": 1000000,
   145|    "claude-opus-4.7": 1000000,
   146|    "claude-opus-4-6": 1000000,
   147|    "claude-sonnet-4-6": 1000000,
   148|    "claude-opus-4.6": 1000000,
   149|    "claude-sonnet-4.6": 1000000,
   150|    # Catch-all for older Claude models (must sort after specific entries)
   151|    "claude": 200000,
   152|    # OpenAI — GPT-5 family (most have 400k; specific overrides first)
   153|    # Source: https://developers.openai.com/api/docs/models
   154|    # GPT-5.5 (launched Apr 23 2026) is 1.05M on the direct OpenAI API and
   155|    # ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own
   156|    # provider-aware branches (_resolve_codex_oauth_context_length + models.dev).
   157|    # This hardcoded value is only reached when every probe misses.
   158|    "gpt-5.5": 1050000,
   159|    "gpt-5.4-nano": 400000,           # 400k (not 1.05M like full 5.4)
   160|    "gpt-5.4-mini": 400000,           # 400k (not 1.05M like full 5.4)
   161|    "gpt-5.4": 1050000,               # GPT-5.4, GPT-5.4 Pro (1.05M context)
   162|    # gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) and
   163|    # uses a smaller 128k window than other gpt-5.x slugs. Listed here as
   164|    # a defensive override so the longest-substring fallback doesn't match
   165|    # the generic "gpt-5" entry below (400k) and report the wrong limit if
   166|    # Spark's context ever needs to be resolved through this path. Real
   167|    # usage flows through _CODEX_OAUTH_CONTEXT_FALLBACK at line ~1113.
   168|    "gpt-5.3-codex-spark": 128000,
   169|    "gpt-5.1-chat": 128000,           # Chat variant has 128k context
   170|    "gpt-5": 400000,                  # GPT-5.x base, mini, codex variants (400k)
   171|    "gpt-4.1": 1047576,
   172|    "gpt-4": 128000,
   173|    # Google
   174|    "gemini": 1048576,
   175|    # Gemma (open models served via AI Studio)
   176|    "gemma-4": 256000,  # Gemma 4 family
   177|    "gemma4": 256000,  # Ollama-style naming (e.g. gemma4:31b-cloud)
   178|    "gemma-4-31b": 256000,
   179|    "gemma-3": 131072,
   180|    "gemma": 8192,  # fallback for older gemma models
   181|    # DeepSeek — V4 family ships with a 1M context window. The legacy
   182|    # aliases ``deepseek-chat`` / ``deepseek-reasoner`` are server-side
   183|    # mapped to the non-thinking / thinking modes of ``deepseek-v4-flash``
   184|    # and inherit the same 1M window. The ``deepseek`` substring entry
   185|    # below remains as a 128K fallback for older / unknown DeepSeek model
   186|    # ids (e.g. via custom endpoints).
   187|    # https://api-docs.deepseek.com/zh-cn/quick_start/pricing
   188|    "deepseek-v4-pro": 1_000_000,
   189|    "deepseek-v4-flash": 1_000_000,
   190|    "deepseek-chat": 1_000_000,
   191|    "deepseek-reasoner": 1_000_000,
   192|    "deepseek": 128000,
   193|    # Meta
   194|    "llama": 131072,
   195|    # Qwen — specific model families before the catch-all.
   196|    # Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
   197|    "qwen3-coder-plus": 1000000,  # 1M context
   198|    "qwen3-coder": 262144,        # 256K context
   199|    "qwen": 131072,
   200|    # MiniMax — official docs: 204,800 context for all models
   201|    # https://platform.minimax.io/docs/api-reference/text-anthropic-api
   202|    "minimax": 204800,
   203|    # GLM
   204|    "glm": 202752,
   205|    # xAI Grok — xAI /v1/models does not return context_length metadata,
   206|    # so these hardcoded fallbacks prevent Hermes from probing-down to
   207|    # the default 128k when the user points at https://api.x.ai/v1
   208|    # via a custom provider. Values sourced from models.dev (2026-04).
   209|    # Keys use substring matching (longest-first), so e.g. "grok-4.20"
   210|    # matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
   211|    "grok-code-fast": 256000,   # grok-code-fast-1
   212|    "grok-4-1-fast": 2000000,   # grok-4-1-fast-(non-)reasoning
   213|    "grok-2-vision": 8192,      # grok-2-vision, -1212, -latest
   214|    "grok-4-fast": 2000000,     # grok-4-fast-(non-)reasoning
   215|    "grok-4.20": 2000000,       # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
   216|    "grok-4.3": 1000000,        # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai
   217|    "grok-4": 256000,           # grok-4, grok-4-0709
   218|    "grok-3": 131072,           # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
   219|    "grok-2": 131072,           # grok-2, grok-2-1212, grok-2-latest
   220|    "grok": 131072,             # catch-all (grok-beta, unknown grok-*)
   221|    # Kimi
   222|    "kimi": 262144,
   223|    # Tencent — Hy3 Preview (Hunyuan) with 256K context window.
   224|    # OpenRouter live metadata reports 262144 (256 × 1024); align the
   225|    # static fallback so cache and offline both agree (issue #22268).
   226|    "hy3-preview": 262144,
   227|    # Nemotron — NVIDIA's open-weights series (128K context across all sizes)
   228|    "nemotron": 131072,
   229|    # Arcee
   230|    "trinity": 262144,
   231|    # OpenRouter
   232|    "elephant": 262144,
   233|    # Hugging Face Inference Providers — model IDs use org/name format
   234|    "Qwen/Qwen3.5-397B-A17B": 131072,
   235|    "Qwen/Qwen3.5-35B-A3B": 131072,
   236|    "deepseek-ai/DeepSeek-V3.2": 65536,
   237|    "moonshotai/Kimi-K2.5": 262144,
   238|    "moonshotai/Kimi-K2.6": 262144,
   239|    "moonshotai/Kimi-K2-Thinking": 262144,
   240|    "MiniMaxAI/MiniMax-M2.5": 204800,
   241|    "XiaomiMiMo/MiMo-V2-Flash": 262144,
   242|    "mimo-v2-pro": 1048576,
   243|    "mimo-v2.5-pro": 1048576,
   244|    "mimo-v2.5": 1048576,
   245|    "mimo-v2-omni": 262144,
   246|    "mimo-v2-flash": 262144,
   247|    "zai-org/GLM-5": 202752,
   248|}
   249|
   250|# xAI Grok models that ACCEPT the `reasoning.effort` parameter on
   251|# api.x.ai. Verified live against /v1/responses 2026-05-10:
   252|#
   253|#   ACCEPTS effort:  grok-3-mini, grok-3-mini-fast, grok-4.20-multi-agent-0309,
   254|#                    grok-4.3
   255|#   REJECTS effort:  grok-3, grok-4, grok-4-0709, grok-4-fast-(non-)reasoning,
   256|#                    grok-4-1-fast-(non-)reasoning, grok-4.20-0309-(non-)reasoning,
   257|#                    grok-code-fast-1
   258|#
   259|# REJECTS-side models still reason natively — they just don't expose an
   260|# effort dial — so callers should send no `reasoning` key at all rather
   261|

Use offset=261 to continue reading (showing 1-260 of 1826 lines)


  1060|            if resp.status_code != 200:
  1061|                return None
  1062|            data = resp.json()
  1063|
  1064|            # Hosted Ollama: GGUF model_info is the real max — prefer it over
  1065|            # num_ctx which the Cloud operator may have capped arbitrarily.
  1066|            model_info = data.get("model_info", {})
  1067|            for key, value in model_info.items():
  1068|                if "context_length" in key and isinstance(value, (int, float)):
  1069|                    ctx = int(value)
  1070|                    if ctx >= 1024:
  1071|                        return ctx
  1072|
  1073|            # Fall back to num_ctx from Modelfile parameters (rare on Cloud)
  1074|            params = data.get("parameters", "")
  1075|            if "num_ctx" in params:
  1076|                for line in params.split("\n"):
  1077|                    if "num_ctx" in line:
  1078|                        parts = line.strip().split()
  1079|                        if len(parts) >= 2:
  1080|                            try:
  1081|                                ctx = int(parts[-1])
  1082|                                if ctx >= 1024:
  1083|                                    return ctx
  1084|                            except ValueError:
  1085|                                pass
  1086|    except Exception:
  1087|        pass
  1088|    return None
  1089|
  1090|
  1091|def _model_name_suggests_kimi(model: str) -> bool:
  1092|    """Return True if the model name looks like a Kimi-family model.
  1093|
  1094|    Catches ``kimi-k2.6``, ``kimi-k2.5``, ``kimi-k2-thinking``,
  1095|    ``moonshotai/Kimi-K2.6``, and similar variants.  Used as a guard
  1096|    against stale OpenRouter metadata that underreports these models
  1097|    as 32K context when they actually support 262K+.
  1098|    """
  1099|    lower = model.lower()
  1100|    return lower.startswith("kimi") or "moonshot" in lower
  1101|
  1102|
  1103|def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
  1104|    """Query a local server for the model's context length."""
  1105|    import httpx
  1106|
  1107|    # Strip recognised provider prefix (e.g., "local:model-name" → "model-name").
  1108|    # Ollama "model:tag" colons (e.g. "qwen3.5:27b") are intentionally preserved.
  1109|    model = _strip_provider_prefix(model)
  1110|
  1111|    # Strip /v1 suffix to get the server root
  1112|    server_url = base_url.rstrip("/")
  1113|    if server_url.endswith("/v1"):
  1114|        server_url = server_url[:-3]
  1115|
  1116|    headers = _auth_headers(api_key)
  1117|
  1118|    try:
  1119|        server_type = detect_local_server_type(base_url, api_key=api_key)
  1120|    except Exception:
  1121|        server_type = None
  1122|
  1123|    try:
  1124|        with httpx.Client(timeout=3.0, headers=headers) as client:
  1125|            # Ollama: /api/show returns model details with context info
  1126|            if server_type == "ollama":
  1127|                resp = client.post(f"{server_url}/api/show", json={"name": model})
  1128|                if resp.status_code == 200:
  1129|                    data = resp.json()
  1130|                    # Prefer explicit num_ctx from Modelfile parameters: this is
  1131|                    # the *runtime* context Ollama will actually allocate KV cache
  1132|                    # for. The GGUF model_info.context_length is the training max,
  1133|                    # which can be larger than num_ctx — using it here would let
  1134|                    # Hermes grow conversations past the runtime limit and Ollama
  1135|                    # would silently truncate. Matches query_ollama_num_ctx().
  1136|                    params = data.get("parameters", "")
  1137|                    if "num_ctx" in params:
  1138|                        for line in params.split("\n"):
  1139|                            if "num_ctx" in line:
  1140|                                parts = line.strip().split()
  1141|                                if len(parts) >= 2:
  1142|                                    try:
  1143|                                        return int(parts[-1])
  1144|                                    except ValueError:
  1145|                                        pass
  1146|                    # Fall back to GGUF model_info context_length (training max)
  1147|                    model_info = data.get("model_info", {})
  1148|                    for key, value in model_info.items():
  1149|                        if "context_length" in key and isinstance(value, (int, float)):
  1150|                            return int(value)
  1151|
  1152|            # LM Studio native API: /api/v1/models returns max_context_length.
  1153|            # This is more reliable than the OpenAI-compat /v1/models which
  1154|            # doesn't include context window information for LM Studio servers.
  1155|            # Use _model_id_matches for fuzzy matching: LM Studio stores models as
  1156|            # "publisher/slug" but users configure only "slug" after "local:" prefix.
  1157|            if server_type == "lm-studio":
  1158|                resp = client.get(f"{server_url}/api/v1/models")
  1159|                if resp.status_code == 200:
  1160|                    data = resp.json()
  1161|                    for m in data.get("models", []):
  1162|                        if _model_id_matches(m.get("key", ""), model) or _model_id_matches(m.get("id", ""), model):
  1163|                            # Prefer loaded instance context (actual runtime value)
  1164|                            for inst in m.get("loaded_instances", []):
  1165|                                cfg = inst.get("config", {})
  1166|                                ctx = cfg.get("context_length")
  1167|                                if ctx and isinstance(ctx, (int, float)):
  1168|                                    return int(ctx)
  1169|                            break
  1170|
  1171|            # LM Studio / vLLM / llama.cpp: try /v1/models/{model}
  1172|            resp = client.get(f"{server_url}/v1/models/{model}")
  1173|            if resp.status_code == 200:
  1174|                data = resp.json()
  1175|                # vLLM returns max_model_len
  1176|                ctx = data.get("max_model_len") or data.get("context_length") or data.get("max_tokens")
  1177|                if ctx and isinstance(ctx, (int, float)):
  1178|                    return int(ctx)
  1179|
  1180|            # Try /v1/models and find the model in the list.
  1181|            # Use _model_id_matches to handle "publisher/slug" vs bare "slug".
  1182|            resp = client.get(f"{server_url}/v1/models")
  1183|            if resp.status_code == 200:
  1184|                data = resp.json()
  1185|                models_list = data.get("data", [])
  1186|                for m in models_list:
  1187|                    if _model_id_matches(m.get("id", ""), model):
  1188|                        ctx = m.get("max_model_len") or m.get("context_length") or m.get("max_tokens")
  1189|                        if ctx and isinstance(ctx, (int, float)):
  1190|                            return int(ctx)
  1191|    except Exception:
  1192|        pass
  1193|
  1194|    return None
  1195|
  1196|
  1197|def _normalize_model_version(model: str) -> str:
  1198|    """Normalize version separators for matching.
  1199|
  1200|    Nous uses dashes: claude-opus-4-6, claude-sonnet-4-5
  1201|    OpenRouter uses dots: claude-opus-4.6, claude-sonnet-4.5
  1202|    Normalize both to dashes for comparison.
  1203|    """
  1204|    return model.replace(".", "-")
  1205|
  1206|
  1207|def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> Optional[int]:
  1208|    """Query Anthropic's /v1/models endpoint for context length.
  1209|
  1210|    Only works with regular ANTHROPIC_API_KEY (sk-ant-api*).
  1211|    OAuth tokens (sk-ant-oat*) from Claude Code return 401.
  1212|    """
  1213|    if not api_key or api_key.startswith("sk-ant-oat"):
  1214|        return None  # OAuth tokens can't access /v1/models
  1215|    try:
  1216|        base = base_url.rstrip("/")
  1217|        if base.endswith("/v1"):
  1218|            base = base[:-3]
  1219|        url = f"{base}/v1/models?limit=1000"
  1220|        headers = {
  1221|            "x-api-key": api_key,
  1222|            "anthropic-version": "2023-06-01",
  1223|        }
  1224|        resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
  1225|        if resp.status_code != 200:
  1226|            return None
  1227|        data = resp.json()
  1228|        for m in data.get("data", []):
  1229|            if m.get("id") == model:
  1230|                ctx = m.get("max_input_tokens")
  1231|                if isinstance(ctx, int) and ctx > 0:
  1232|                    return ctx
  1233|    except Exception as e:
  1234|        logger.debug("Anthropic /v1/models query failed: %s", e)
  1235|    return None
  1236|
  1237|
  1238|# Known ChatGPT Codex OAuth context windows (observed via live
  1239|# chatgpt.com/backend-api/codex/models probe, Apr 2026). These are the
  1240|# `context_window` values, which are what Codex actually enforces — the
  1241|# direct OpenAI API has larger limits for the same slugs, but Codex OAuth
  1242|# caps lower (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex).
  1243|#
  1244|# Used as a fallback when the live probe fails (no token, network error).
  1245|# Longest keys first so substring match picks the most specific entry.
  1246|_CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
  1247|    "gpt-5.1-codex-max": 272_000,
  1248|    "gpt-5.1-codex-mini": 272_000,
  1249|    "gpt-5.3-codex": 272_000,
  1250|    # Spark runs on specialised low-latency hardware and exposes a smaller
  1251|    # 128k window than other Codex OAuth slugs. Listed explicitly so the
  1252|    # longest-key-first fallback resolves it correctly — substring match
  1253|    # on "gpt-5.3-codex" otherwise wins and reports 272k. Availability is
  1254|    # gated by ChatGPT Pro entitlement on the Codex backend.
  1255|    "gpt-5.3-codex-spark": 128_000,
  1256|    "gpt-5.2-codex": 272_000,
  1257|    "gpt-5.4-mini": 272_000,
  1258|    "gpt-5.5": 272_000,
  1259|    "gpt-5.4": 272_000,
  1260|    "gpt-5.2": 272_000,
  1261|    "gpt-5": 272_000,
  1262|}
  1263|
  1264|
  1265|_codex_oauth_context_cache: Dict[str, int] = {}
  1266|_codex_oauth_context_cache_time: float = 0.0
  1267|_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600  # 1 hour
  1268|
  1269|
  1270|def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
  1271|    """Probe the ChatGPT Codex /models endpoint for per-slug context windows.
  1272|
  1273|    Codex OAuth imposes its own context limits that differ from the direct
  1274|    OpenAI API (e.g. gpt-5.5 is 1.05M on the API, 272K on Codex). The
  1275|    `context_window` field in each model entry is the authoritative source.
  1276|
  1277|    Returns a ``{slug: context_window}`` dict. Empty on failure.
  1278|    """
  1279|    global _codex_oauth_context_cache, _codex_oauth_context_cache_time
  1280|    now = time.time()
  1281|    if (
  1282|        _codex_oauth_context_cache
  1283|        and now - _codex_oauth_context_cache_time < _CODEX_OAUTH_CONTEXT_CACHE_TTL
  1284|    ):
  1285|        return _codex_oauth_context_cache
  1286|
  1287|    try:
  1288|        resp = requests.get(
  1289|            "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
  1290|            headers={"Authorization": f"Bearer {access_token}"},
  1291|            timeout=10,
  1292|            verify=_resolve_requests_verify(),
  1293|        )
  1294|        if resp.status_code != 200:
  1295|            logger.debug(
  1296|                "Codex /models probe returned HTTP %s; falling back to hardcoded defaults",
  1297|                resp.status_code,
  1298|            )
  1299|            return {}
  1300|        data = resp.json()
  1301|    except Exception as exc:
  1302|        logger.debug("Codex /models probe failed: %s", exc)
  1303|        return {}
  1304|
  1305|    entries = data.get("models", []) if isinstance(data, dict) else []
  1306|    result: Dict[str, int] = {}
  1307|    for item in entries:
  1308|        if not isinstance(item, dict):
  1309|            continue
  1310|        slug = item.get("slug")
  1311|        ctx = item.get("context_window")
  1312|        if isinstance(slug, str) and isinstance(ctx, int) and ctx > 0:
  1313|            result[slug.strip()] = ctx
  1314|
  1315|    if result:
  1316|        _codex_oauth_context_cache = result
  1317|        _codex_oauth_context_cache_time = now
  1318|    return result
  1319|
  1320|

Use offset=1320 to continue reading (showing 1060-1319 of 1826 lines)


  1320|
  1321|def _resolve_codex_oauth_context_length(
  1322|    model: str, access_token: str = ""
  1323|) -> Optional[int]:
  1324|    """Resolve a Codex OAuth model's real context window.
  1325|
  1326|    Prefers a live probe of chatgpt.com/backend-api/codex/models (when we
  1327|    have a bearer token), then falls back to ``_CODEX_OAUTH_CONTEXT_FALLBACK``.
  1328|    """
  1329|    model_bare = _strip_provider_prefix(model).strip()
  1330|    if not model_bare:
  1331|        return None
  1332|
  1333|    if access_token:
  1334|        live = _fetch_codex_oauth_context_lengths(access_token)
  1335|        if model_bare in live:
  1336|            return live[model_bare]
  1337|        # Case-insensitive match in case casing drifts
  1338|        model_lower = model_bare.lower()
  1339|        for slug, ctx in live.items():
  1340|            if slug.lower() == model_lower:
  1341|                return ctx
  1342|
  1343|    # Fallback: longest-key-first substring match over hardcoded defaults.
  1344|    model_lower = model_bare.lower()
  1345|    for slug, ctx in sorted(
  1346|        _CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True
  1347|    ):
  1348|        if slug in model_lower:
  1349|            return ctx
  1350|
  1351|    return None
  1352|
  1353|
  1354|def _resolve_nous_context_length(
  1355|    model: str,
  1356|    base_url: str = "",
  1357|    api_key: str = "",
  1358|) -> Tuple[Optional[int], str]:
  1359|    """Resolve Nous Portal model context length.
  1360|
  1361|    Tries the live Nous inference endpoint first (authoritative), then falls
  1362|    back to OpenRouter metadata with suffix/version matching.
  1363|
  1364|    Nous model IDs are bare after prefix-stripping (e.g. 'qwen3.6-plus',
  1365|    'claude-opus-4-6') while OpenRouter uses prefixed IDs (e.g.
  1366|    'qwen/qwen3.6-plus', 'anthropic/claude-opus-4.6').  Version
  1367|    normalization (dot↔dash) is applied to handle name drifts.
  1368|
  1369|    Returns ``(context_length, source)`` where ``source`` is one of:
  1370|      - ``"portal"``    — live /v1/models response (authoritative)
  1371|      - ``"openrouter"`` — OpenRouter cache fallback (non-authoritative;
  1372|        callers must NOT persist this to the on-disk cache or a single
  1373|        portal blip will freeze the wrong value in forever)
  1374|      - ``""``           — could not resolve
  1375|    """
  1376|    # Portal first — the Nous /models endpoint is authoritative for what our
  1377|    # infrastructure enforces and may differ from OR (e.g. OR reports 1M for
  1378|    # qwen3.6-plus; the portal correctly says 262144).  Fall back to the OR
  1379|    # catalog only if the portal doesn't list the model.
  1380|    if base_url:
  1381|        portal_ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
  1382|        if portal_ctx is not None:
  1383|            return portal_ctx, "portal"
  1384|
  1385|    metadata = fetch_model_metadata()
  1386|
  1387|    def _safe_ctx(or_id: str, entry: dict) -> Optional[int]:
  1388|        ctx = entry.get("context_length")
  1389|        if ctx is None:
  1390|            return None
  1391|        if ctx <= 32768 and _model_name_suggests_kimi(or_id):
  1392|            logger.info(
  1393|                "Rejecting OpenRouter metadata context=%s for %r "
  1394|                "(Kimi-family underreport, Nous path); falling through to hardcoded defaults",
  1395|                ctx, or_id,
  1396|            )
  1397|            return None
  1398|        return ctx
  1399|
  1400|    if model in metadata:
  1401|        ctx = _safe_ctx(model, metadata[model])
  1402|        if ctx is not None:
  1403|            return ctx, "openrouter"
  1404|
  1405|    normalized = _normalize_model_version(model).lower()
  1406|
  1407|    for or_id, entry in metadata.items():
  1408|        bare = or_id.split("/", 1)[1] if "/" in or_id else or_id
  1409|        if bare.lower() == model.lower() or _normalize_model_version(bare).lower() == normalized:
  1410|            ctx = _safe_ctx(or_id, entry)
  1411|            if ctx is not None:
  1412|                return ctx, "openrouter"
  1413|
  1414|    model_lower = model.lower()
  1415|    for or_id, entry in metadata.items():
  1416|        bare = or_id.split("/", 1)[1] if "/" in or_id else or_id
  1417|        for candidate, query in [(bare.lower(), model_lower), (_normalize_model_version(bare).lower(), normalized)]:
  1418|            if candidate.startswith(query) and (
  1419|                len(candidate) == len(query) or candidate[len(query)] in "-:."
  1420|            ):
  1421|                ctx = _safe_ctx(or_id, entry)
  1422|                if ctx is not None:
  1423|                    return ctx, "openrouter"
  1424|
  1425|    return None, ""
  1426|
  1427|
  1428|def get_model_context_length(
  1429|    model: str,
  1430|    base_url: str = "",
  1431|    api_key: str = "",
  1432|    config_context_length: int | None = None,
  1433|    provider: str = "",
  1434|    custom_providers: list | None = None,
  1435|) -> int:
  1436|    """Get the context length for a model.
  1437|
  1438|    Resolution order:
  1439|    0. Explicit config override (model.context_length or custom_providers per-model)
  1440|    1. Persistent cache (previously discovered via probing).  Nous URLs
  1441|       bypass the cache here so step 5b can always reconcile against
  1442|       the authoritative portal /v1/models response.
  1443|    1b. AWS Bedrock static table (must precede custom-endpoint probe)
  1444|    2. Active endpoint metadata (/models for explicit custom endpoints)
  1445|    3. Local server query (for local endpoints)
  1446|    4. Anthropic /v1/models API (API-key users only, not OAuth)
  1447|    5. Provider-aware lookups (before generic OpenRouter cache):
  1448|       a. Copilot live /models API
  1449|       b. Nous: live /v1/models probe first (authoritative), then OR
  1450|          cache fallback with suffix/version normalisation.  Only
  1451|          portal-derived values are persisted to disk.
  1452|       c. Codex OAuth /models probe
  1453|       d. GMI /models endpoint
  1454|       e. Ollama native /api/show probe (any base_url, provider-agnostic)
  1455|       f. models.dev registry lookup (with :cloud/-cloud suffix fallback)
  1456|    6. OpenRouter live API metadata (Kimi-family 32k guard)
  1457|    7. Hardcoded defaults (broad family patterns, longest-key-first)
  1458|    8. Local server query (last resort)
  1459|    9. Default fallback (256K)"""
  1460|    # 0. Explicit config override — user knows best
  1461|    if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
  1462|        return config_context_length
  1463|
  1464|    # 0b. custom_providers per-model override — check before any probe.
  1465|    # This closes the gap where /model switch and display paths used to fall
  1466|    # back to 128K despite the user having a per-model context_length set.
  1467|    # See #15779.
  1468|    if custom_providers and base_url and model:
  1469|        try:
  1470|            from hermes_cli.config import get_custom_provider_context_length
  1471|            cp_ctx = get_custom_provider_context_length(
  1472|                model=model,
  1473|                base_url=base_url,
  1474|                custom_providers=custom_providers,
  1475|            )
  1476|            if cp_ctx:
  1477|                return cp_ctx
  1478|        except Exception:
  1479|            pass  # fall through to probing
  1480|
  1481|    # Normalise provider-prefixed model names (e.g. "local:model-name" →
  1482|    # "model-name") so cache lookups and server queries use the bare ID that
  1483|    # local servers actually know about.  Ollama "model:tag" colons are preserved.
  1484|    model = _strip_provider_prefix(model)
  1485|
  1486|    # 1. Check persistent cache (model+provider)
  1487|    # LM Studio is excluded — its loaded context length is transient (the
  1488|    # user can reload the model with a different context_length at any time
  1489|    # via /api/v1/models/load), so a stale cached value would mask reloads.
  1490|    if base_url and provider != "lmstudio":
  1491|        cached = get_cached_context_length(model, base_url)
  1492|        if cached is not None:
  1493|            # Invalidate stale Codex OAuth cache entries: pre-PR #14935 builds
  1494|            # resolved gpt-5.x to the direct-API value (e.g. 1.05M) via
  1495|            # models.dev and persisted it. Codex OAuth caps at 272K for every
  1496|            # slug, so any cached Codex entry at or above 400K is a leftover
  1497|            # from the old resolution path. Drop it and fall through to the
  1498|            # live /models probe in step 5 below.
  1499|            if provider == "openai-codex" and cached >= 400_000:
  1500|                logger.info(
  1501|                    "Dropping stale Codex cache entry %s@%s -> %s (pre-fix value); "
  1502|                    "re-resolving via live /models probe",
  1503|                    model, base_url, f"{cached:,}",
  1504|                )
  1505|                _invalidate_cached_context_length(model, base_url)
  1506|            # Invalidate stale 32k cache entries for Kimi-family models.
  1507|            elif cached <= 32768 and _model_name_suggests_kimi(model):
  1508|                logger.info(
  1509|                    "Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); "
  1510|                    "re-resolving via hardcoded defaults",
  1511|                    model, base_url, f"{cached:,}",
  1512|                )
  1513|                _invalidate_cached_context_length(model, base_url)
  1514|            # Nous Portal: the portal /v1/models endpoint is authoritative.
  1515|            # Bypass the persistent cache so step 5b can always reconcile
  1516|            # against it — this corrects pre-fix entries seeded from the
  1517|            # OR catalog (the same OR underreport class that the Kimi/Qwen
  1518|            # DEFAULT_CONTEXT_LENGTHS overrides exist to mitigate) without
  1519|            # touching the on-disk file when the portal is unreachable.
  1520|            # The in-memory 300s endpoint metadata cache makes the per-call
  1521|            # cost amortise to ~0 within a process.
  1522|            elif _infer_provider_from_url(base_url) == "nous":
  1523|                logger.debug(
  1524|                    "Bypassing persistent cache for %s@%s (Nous portal authoritative)",
  1525|                    model, base_url,
  1526|                )
  1527|                # Fall through; step 5b reconciles and overwrites if portal responds.
  1528|            else:
  1529|                return cached
  1530|
  1531|    # 1b. AWS Bedrock — use static context length table.
  1532|    # Bedrock's ListFoundationModels API doesn't expose context window sizes,
  1533|    # so we maintain a curated table in bedrock_adapter.py that reflects
  1534|    # AWS-imposed limits (e.g. 200K for Claude models vs 1M on the native
  1535|    # Anthropic API).  This must run BEFORE the custom-endpoint probe at
  1536|    # step 2 — bedrock-runtime.<region>.amazonaws.com is not in
  1537|    # _URL_TO_PROVIDER, so it would otherwise be treated as a custom endpoint,
  1538|    # fail the /models probe (Bedrock doesn't expose that shape), and fall
  1539|    # back to the 128K default before reaching the original step 4b branch.
  1540|

Use offset=1540 to continue reading (showing 1320-1539 of 1826 lines)


  1540|    if provider == "bedrock" or (
  1541|        base_url
  1542|        and base_url_hostname(base_url).startswith("bedrock-runtime.")
  1543|        and base_url_host_matches(base_url, "amazonaws.com")
  1544|    ):
  1545|        try:
  1546|            from agent.bedrock_adapter import get_bedrock_context_length
  1547|            return get_bedrock_context_length(model)
  1548|        except ImportError:
  1549|            pass  # boto3 not installed — fall through to generic resolution
  1550|
  1551|    if provider == "novita" or (base_url and base_url_host_matches(base_url, "api.novita.ai")):
  1552|        ctx = _resolve_endpoint_context_length(model, base_url or "https://api.novita.ai/openai/v1", api_key=api_key)
  1553|        if ctx is not None:
  1554|            if base_url:
  1555|                save_context_length(model, base_url, ctx)
  1556|            return ctx
  1557|
  1558|    # 2. Active endpoint metadata for truly custom/unknown endpoints.
  1559|    # Known providers (Copilot, OpenAI, Anthropic, etc.) skip this — their
  1560|    # /models endpoint may report a provider-imposed limit (e.g. Copilot
  1561|    # returns 128k) instead of the model's full context (400k).  models.dev
  1562|    # has the correct per-provider values and is checked at step 5+.
  1563|    if _is_custom_endpoint(base_url) and not _is_known_provider_base_url(base_url):
  1564|        context_length = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
  1565|        if context_length is not None:
  1566|            return context_length
  1567|        if not _is_known_provider_base_url(base_url):
  1568|            # 2b. Ollama native /api/show — any URL might be an Ollama server
  1569|            # (local, cloud, or custom hosting).  Non-Ollama servers return
  1570|            # 404/405 quickly.  Fall through on failure.
  1571|            ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
  1572|            if ctx is not None:
  1573|                save_context_length(model, base_url, ctx)
  1574|                return ctx
  1575|            # 3. Try querying local server directly
  1576|            if is_local_endpoint(base_url):
  1577|                local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
  1578|                if local_ctx and local_ctx > 0:
  1579|                    if provider != "lmstudio":
  1580|                        save_context_length(model, base_url, local_ctx)
  1581|                    return local_ctx
  1582|            logger.info(
  1583|                "Could not detect context length for model %r at %s — "
  1584|                "defaulting to %s tokens (probe-down). Set model.context_length "
  1585|                "in config.yaml to override.",
  1586|                model, base_url, f"{DEFAULT_FALLBACK_CONTEXT:,}",
  1587|            )
  1588|            return DEFAULT_FALLBACK_CONTEXT
  1589|
  1590|    # 4. Anthropic /v1/models API (only for regular API keys, not OAuth)
  1591|    if provider == "anthropic" or (
  1592|        base_url and base_url_hostname(base_url) == "api.anthropic.com"
  1593|    ):
  1594|        ctx = _query_anthropic_context_length(model, base_url or "https://api.anthropic.com", api_key)
  1595|        if ctx:
  1596|            return ctx
  1597|
  1598|    # 4b. (Bedrock handled earlier at step 1b — before custom-endpoint probe.)
  1599|
  1600|    # 5. Provider-aware lookups (before generic OpenRouter cache)
  1601|    # These are provider-specific and take priority over the generic OR cache,
  1602|    # since the same model can have different context limits per provider
  1603|    # (e.g. claude-opus-4.6 is 1M on Anthropic but 128K on GitHub Copilot).
  1604|    # If provider is generic (openrouter/custom/empty), try to infer from URL.
  1605|    effective_provider = provider
  1606|    if not effective_provider or effective_provider in {"openrouter", "custom"}:
  1607|        if base_url:
  1608|            inferred = _infer_provider_from_url(base_url)
  1609|            if inferred:
  1610|                effective_provider = inferred
  1611|
  1612|    # 5a. Copilot live /models API — max_prompt_tokens from the user's account.
  1613|    # This catches account-specific models (e.g. claude-opus-4.6-1m) that
  1614|    # don't exist in models.dev. For models that ARE in models.dev, this
  1615|    # returns the provider-enforced limit which is what users can actually use.
  1616|    if effective_provider in {"copilot", "copilot-acp", "github-copilot"}:
  1617|        try:
  1618|            from hermes_cli.models import get_copilot_model_context
  1619|            ctx = get_copilot_model_context(model, api_key=api_key)
  1620|            if ctx:
  1621|                return ctx
  1622|        except Exception:
  1623|            pass  # Fall through to models.dev
  1624|
  1625|    if effective_provider == "nous":
  1626|        ctx, source = _resolve_nous_context_length(
  1627|            model, base_url=base_url or "", api_key=api_key or ""
  1628|        )
  1629|        if ctx:
  1630|            # Persist ONLY portal-derived values.  Caching an OR-fallback
  1631|            # value here would freeze in a wrong number on the first portal
  1632|            # blip / auth glitch and step-1 would short-circuit it forever.
  1633|            # OR's catalog is community-maintained and is precisely why the
  1634|            # Kimi/Qwen DEFAULT_CONTEXT_LENGTHS overrides exist — we don't
  1635|            # want it leaking into the persistent cache for Nous URLs.
  1636|            if base_url and source == "portal":
  1637|                save_context_length(model, base_url, ctx)
  1638|            return ctx
  1639|    if effective_provider == "openai-codex":
  1640|        # Codex OAuth enforces lower context limits than the direct OpenAI
  1641|        # API for the same slug (e.g. gpt-5.5 is 1.05M on the API but 272K
  1642|        # on Codex). Authoritative source is Codex's own /models endpoint.
  1643|        codex_ctx = _resolve_codex_oauth_context_length(model, access_token=api_key or "")
  1644|        if codex_ctx:
  1645|            if base_url:
  1646|                save_context_length(model, base_url, codex_ctx)
  1647|            return codex_ctx
  1648|    if effective_provider == "gmi" and base_url:
  1649|        # GMI exposes authoritative context_length via /models, but it is not
  1650|        # in models.dev yet. Preserve that higher-fidelity endpoint lookup.
  1651|        ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
  1652|        if ctx is not None:
  1653|            return ctx
  1654|    # 5e. Ollama native /api/show probe — runs for ANY provider with a
  1655|    # base_url, not just ollama-cloud.  Ollama-compatible servers expose
  1656|    # this endpoint regardless of hostname (local Ollama, Ollama Cloud,
  1657|    # custom Ollama hosting).  The OpenAI-compat /v1/models endpoint
  1658|    # correctly omits context_length per the OpenAI schema, but /api/show
  1659|    # returns the authoritative GGUF model_info.context_length.
  1660|

Use offset=1660 to continue reading (showing 1540-1659 of 1826 lines)


terminal

Output:
0.40: 108,800 tokens
0.50: 136,000 tokens
0.60: 163,200 tokens
0.65: 176,800 tokens
0.70: 190,400 tokens
0.75: 204,000 tokens
0.80: 217,600 tokens
0.85: 231,200 tokens
0.90: 244,800 tokens


hermes config set compression.threshold 0.65 && python - <<'PY'
import yaml, pathlib
cfg=yaml.safe_load(pathlib.Path('/home/agent/.hermes/config.yaml').read_text())
print('compression.threshold =', cfg['compression']['threshold'])
print('effective preflight at Codex GPT-5.5 272k =', int(272000*float(cfg['compression']['threshold'])))
PY


Output:
✓ Set compression.threshold = 0.65 in /home/agent/.hermes/config.yaml
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'yaml'


   141|compression:
   142|  enabled: true
   143|  threshold: 0.65
   144|  target_ratio: 0.15
   145|  protect_last_n: 24
   146|  hygiene_hard_message_limit: 400
   147|  protect_first_n: 3
   148|prompt_caching:
   149|

Use offset=149 to continue reading (showing 141-148 of 725 lines)


Ja, Sir — und ich habe es bereits moderat erhöht.

## Befund

Aktuell nutzt Hermes:

- **Provider:** `openai-codex`
- **Model:** `gpt-5.5`
- **Endpoint:** `https://chatgpt.com/backend-api/codex`
- **Effektives Kontextfenster über Codex OAuth:** ca. **272,000 Tokens**
- **Bisheriger Compression-Trigger:** `compression.threshold: 0.4`
- Daraus kam exakt die Meldung:
  - `272,000 × 0.40 = 108,800`
  - also: `~138,891 tokens >= 108,800 threshold`

Das heisst: Wir hatten nicht nur ein kleines Kontextfenster — wir haben Hermes angewiesen, bereits bei **40 %** des verfügbaren Codex-Fensters zu komprimieren. Etwas vorsichtig. Schweizerisch konservativ, könnte man sagen.

## Änderung

Ich habe gesetzt:

```yaml
compression:
  threshold: 0.65
```

Verifiziert in:

```text
/home/agent/.hermes/config.yaml
compression.threshold: 0.65
```

Neuer Preflight-Trigger bei GPT-5.5 via OpenAI Codex:

```text
272,000 × 0.65 = 176,800 Tokens
```

Die konkrete Meldung mit `~138,891 tokens` würde damit **nicht mehr sofort triggern**.

## Was GPT-5.5 hier wirklich zulässt

Wichtig: Es gibt zwei verschiedene Dinge:

- **GPT-5.5 über direct OpenAI API:** laut Hermes-Metadaten bis ca. **1.05M Tokens**
- **GPT-5.5 über ChatGPT/Codex OAuth backend:** aktuell effektiv ca. **272K Tokens**

Wir nutzen derzeit **OpenAI Codex**, nicht den direct OpenAI API provider. Deshalb ist **272K** die relevante Obergrenze. Einfach `model.context_length` künstlich auf 1M zu setzen wäre keine gute Idee — dann glaubt Hermes an ein Fenster, das der Codex-Backend-Server vermutlich nicht akzeptiert. Klassischer Fall von „wir fahren 300 km/h, weil der Tacho es kann“.

## Empfehlung

Die jetzt gesetzten **0.65** sind ein guter Sweet Spot:

- weniger unnötige Preflight Compression
- noch genug Headroom für:
  - System Prompt
  - Toolschemas
  - Memory
  - letzte Nachrichten
  - Modellantwort
  - Reasoning/Hidden overhead

Wenn es weiter nervt, können wir auf **0.75** gehen:

```text
272,000 × 0.75 = 204,000 Tokens
```

Ich würde aber **nicht über 0.75–0.80** gehen, sonst steigt das Risiko für Context-overflow / API-Fehler deutlich.

## Damit es greift

Für neue Agent-Runs sollte es greifen. Falls die Gateway-Session die alte Config gecacht hat:

- in Telegram: `/reset`
- falls weiterhin alte Schwelle erscheint: `/restart`

Danach sollte der neue Threshold aktiv sein.


[M G] setze  0.75


Explanation:
**Preparing for action**

I need to act now. I have the Hermes configuration ready and loaded—so that’s all set. Now, I just need to make sure we have the right tool to proceed. Once I have everything in place, then I can move on to the final steps. It's all about getting that last bit aligned, and I'm eager to make this happen!

hermes config set compression.threshold 0.75


{"output": "✓ Set compression.threshold = 0.75 in /home/agent/.hermes/config.yaml", "exit_code": 0, "error": null}

[Subdirectory context discovered: .hermes/hermes-agent/AGENTS.md]
# Hermes Agent - Development Guide

Instructions for AI coding assistants and developers working on the hermes-agent codebase.

## Development Environment

```bash
# Prefer .venv; fall back to venv if that's what your checkout has.
source .venv/bin/activate   # or: source venv/bin/activate
```

`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
main checkout).

## Project Structure

File counts shift constantly — don't treat the tree below as exhaustive.
The canonical source is the filesystem. The notes call out the load-bearing
entry points you'll actually edit.

```
hermes-agent/
├── run_agent.py          # AIAgent class — core conversation loop (~12k LOC)
├── model_tools.py        # Tool orchestration, discover_builtin_tools(), handle_function_call()
├── toolsets.py           # Toolset definitions, _HERMES_CORE_TOOLS list
├── cli.py                # HermesCLI class — interactive CLI orchestrator (~11k LOC)
├── hermes_state.py       # SessionDB — SQLite session store (FTS5 search)
├── hermes_constants.py   # get_hermes_home(), display_hermes_home() — profile-aware paths
├── hermes_logging.py     # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
├── batch_runner.py       # Parallel batch processing
├── agent/                # Agent internals (provider adapters, memory, caching, compression, etc.)
├── hermes_cli/           # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/                # Tool implementations — auto-discovered via tools/registry.py
│   └── environments/     # Terminal backends (local, docker, ssh, modal, daytona, singularity)
├── gateway/              # Messaging gateway — run.py + session.py + platforms/
│   ├── platforms/        # Adapter per platform (telegram, discord, slack, whatsapp,
│   │                     #   homeassistant, signal, matrix, mattermost, email, sms,
│   │                     #   dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
│   │                     #   yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│   └── builtin_hooks/    # Extension point for always-registered gateway hooks (none shipped)
├── plugins/              # Plugin system (see "Plugins" section below)
│   ├── memory/           # Memory-provider plugins (honcho, mem0, supermemory, ...)
│   ├── context_engine/   # Context-engine plugins
│   ├── model-providers/  # Inference backend plugins (openrouter, anthropic, gmi, ...)
│   ├── kanban/           # Multi-agent board dispatcher + worker plugin
│   ├── hermes-achievements/  # Gamified achievement tracking
│   ├── observability/    # Metrics / traces / logs plugin
│   ├── image_gen/        # Image-generation providers
│   └── <others>/         # disk-cleanup, example-dashboard, google_meet, platforms,
│                         #   spotify, strike-freedom-cockpit, ...
├── optional-skills/      # Heavier/niche skills shipped but NOT active by default
├── skills/               # Built-in skills bundled with the repo
├── ui-tui/               # Ink (React) terminal UI — `hermes --tui`
│   └── src/              # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
├── tui_gateway/          # Python JSON-RPC backend for the TUI
├── acp_adapter/          # ACP server (VS Code / Zed / JetBrains integration)
├── cron/                 # Scheduler — jobs.py, scheduler.py
├── scripts/              # run_tests.sh, release.py, auxiliary scripts
├── website/              # Docusaurus docs site
└── tests/                # Pytest suite (~17k tests across ~900 files as of May 2026)
```

**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.

## File Dependency Chain

```
tools/registry.py  (no deps — imported by all tool files)
       ↑
tools/*.py  (each calls registry.register() at import time)
       ↑
model_tools.py  (imports tools/registry + triggers tool discovery)
       ↑
run_agent.py, cli.py, batch_runner.py, environments/
```

---

## AIAgent Class (run_agent.py)

The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
session context, budget, credential pool, etc.). The signature below is the
minimum subset you'll usually touch — read `run_agent.py` for the full list.

```python
class AIAgent:
    def __init__(self,
        base_url: str = None,
        api_key: str = None,
        provider: str = None,
        api_mode: str = None,              # "chat_completions" | "codex_responses" | ...
        model: str = "",                   # empty → resolved from config/provider later
        max_iterations: int = 90,          # tool-calling iterations (shared with subagents)
        enabled_toolsets: list = None,
        disabled_toolsets: list = None,
        quiet_mode: bool = False,
        save_trajectories: bool = False,
        platform: str = None,              # "cli", "telegram", etc.
        session_id: str = None,
        skip_context_files: bool = False,
        skip_memory: bool = False,
        credential_pool=None,
        # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
        # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
    ): ...

    def chat(self, message: str) -> str:
        """Simple interface — returns final response string."""

    def run_conversation(self, user_message: str, system_message: str = None,
                         conversation_history: list = None, task_id: str = None) -> dict:
        """Full interface — returns dict with final_response + messages."""
```

### Agent Loop

The core loop is inside `run_conversation()` — entirely synchronous, with
interrupt checks, budget tracking, and a one-turn grace call:

```python
while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
        or self._budget_grace_call:
    if self._interrupt_requested: break
    response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
    if response.tool_calls:
        for tool_call in response.tool_calls:
            result = handle_function_call(tool_call.name, tool_call.args, task_id)
            messages.append(tool_result_message(result))
        api_call_count += 1
    else:
        return response.content
```

Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
Reasoning content is stored in `assistant_msg["reasoning"]`.

---

## CLI Architecture (cli.py)

- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
- **Skin engine** (`hermes_cli/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching

### Slash Command Registry (`hermes_cli/commands.py`)

All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:

- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
- **Gateway help** — `gateway_help_lines()` generat

[...truncated AGENTS.md: 50,982 chars total]


   141|compression:
   142|  enabled: true
   143|  threshold: 0.75
   144|  target_ratio: 0.15
   145|  protect_last_n: 24
   146|  hygiene_hard_message_limit: 400
   147|  protect_first_n: 3
   148|prompt_caching:
   149|


Erledigt, Sir.

```yaml
compression:
  threshold: 0.75
```

Verifiziert in:

```text
/home/agent/.hermes/config.yaml
```

Damit liegt der Preflight-Compression-Trigger bei GPT-5.5 via OpenAI Codex ungefähr bei:

```text
272,000 × 0.75 = 204,000 Tokens
```

Falls die alte Schwelle noch in laufenden Gateway-Sessions auftaucht: `/reset`, notfalls `/restart`.