Files
hermes-agent/hermes_cli/local_runtime/growth.py
emozilla 20700f298a fix(local-runtime): size launch windows beside other programs' GPU memory
Boot presets and in-session growth priced the context window from card
capacity: total VRAM minus a fixed max(2 GiB, 9%) reserve. That reserve
covers a light desktop only. On an RTX 5090 with 4.5-6.3 GiB held by a
browser and other apps, Qwen3.8 27B booted at 216K, overflowed the card,
and Windows paged part of it to host memory without an error: decode
fell from ~90 to ~24 tok/s.

hardware.launch_budget subtracts what other programs hold now (device
total minus nvidia-smi free, minus our own server's footprint) plus
1 GiB of headroom. fit_to_free_memory narrows a resident plan's window
to that budget, down to the floor and never further: weights are never
moved to the CPU on a live reading, which once pinned a fitting model
to the CPU while the reading still counted the outgoing server.

- Boot: preset generation narrows every window through it.
- Idle: every 30 s with no model holding memory, the presets are
  re-planned and the router re-reads them (GET /models?reload=1), so a
  later on-demand load gets a window for the current desktop. The file
  is never rewritten while a model is loaded, because reload unloads a
  loaded model whose flags changed.
- Growth: the next rung must fit beside other programs, counting the
  growing model's own memory as free.

Recommendations, quant selection and catalog pricing keep the capacity
budget. Unified-memory machines and failed probes keep today's plan.
The new tests pin the catalog 27B's window from 0 to 8.5 GiB of other
programs. Every boot, idle, growth and residency-cap test records its
probe_budget calls and fails on any made without planning=True, including
calls inside paths that swallow exceptions.
2026-09-27 22:41:39 -04:00

141 lines
5.7 KiB
Python

"""In-session context growth for the managed llama.cpp runtime.
Scope guard: only a server THIS process supervises grows. Detected external servers and
other-process supervisors keep their own policies.
"""
from __future__ import annotations
from contextlib import suppress
import json
import logging
logger = logging.getLogger(__name__)
def window_overrides_path():
from hermes_cli.local_runtime.binaries import runtimes_root
return runtimes_root() / "window_overrides.json"
def load_window_overrides() -> dict:
"""model_id -> granted window (int). Empty on any read problem."""
try:
with open(window_overrides_path(), encoding="utf-8-sig") as fh:
data = json.load(fh)
return {str(k): int(v) for k, v in data.items()}
except Exception:
return {}
def _write_overrides(overrides: dict) -> None:
path = window_overrides_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(overrides, indent=1), encoding="utf-8")
def save_window_override(model_id: str, window: int) -> None:
overrides = load_window_overrides()
overrides[model_id] = int(window)
_write_overrides(overrides)
def clear_window_override(model_id: str) -> None:
"""Drop a model's growth state (delete/re-download paths)."""
overrides = load_window_overrides()
if model_id in overrides:
del overrides[model_id]
_write_overrides(overrides)
def is_managed_endpoint(base_url: str) -> bool:
"""True when base_url is the server this process's state file points at."""
with suppress(Exception):
from hermes_cli.local_runtime.endpoint import _state_endpoint
state = _state_endpoint()
return state is not None and (
(base_url or "").rstrip("/") == str(state.get("base_url", "")).rstrip("/"))
return False
def maybe_grow_window(model_id: str, *, base_url: str, session_tokens: int,
current_window: int,
measured_decode_tok_s: float | None = None) -> int | None:
"""One growth evaluation + execution. Returns the NEW window when the ladder granted a bigger
one, else None (hold / compress / not ours).
The caller sits at a request boundary by construction (the pre-API compression gate), so
re-prefill growth is safe at any call: the next request rebuilds server state in the larger
window — nothing rewinds.
"""
from hermes_cli.local_runtime.bootstrap import (
_launch_budget, get_supervisor, refresh_local_runtime, staged_models)
from hermes_cli.local_runtime.context_policy import growth_decision
from hermes_cli.local_runtime.estimator import profile_from_gguf
from hermes_cli.local_runtime.gguf import model_id_from_stem, read_gguf_header
from hermes_cli.local_runtime.hardware import probe_budget
from hermes_cli.local_runtime.presets import (
preset_for_model, read_preset_decisions, resident_footprint)
sup = get_supervisor()
if sup is None or not is_managed_endpoint(base_url):
return None
gguf = next((p for p in staged_models() if model_id_from_stem(p.stem) == model_id), None)
if gguf is None:
return None
try:
profile = profile_from_gguf(read_gguf_header(gguf))
except (ValueError, OSError) as exc:
logger.debug("growth skip %s: unreadable gguf (%s)", model_id, exc)
return None
try:
server_idle = sup.is_idle(model_id)
except Exception: # noqa: BLE001
server_idle = False
budget = probe_budget(planning=True)
decision = growth_decision(
# Capacity budget, not live-free: growth executes via a server bounce, so the grown
# instance loads onto a freed card. Live-free is distorted by the very model being grown
# — it reads its own residency as unavailable and vetoes rungs that fit.
profile, budget,
current_window=current_window,
session_tokens=session_tokens,
measured_decode_tok_s=measured_decode_tok_s,
server_idle=server_idle,
# The caller IS the occupancy signal: this runs from the agent's compression gate, which
# fired on its own threshold. Two separately-derived edges must not deadlock into
# compress-before-grow.
occupancy_confirmed=True,
)
if decision.action != "grow" or not decision.next_window:
logger.debug("growth %s: %s (%s)", model_id, decision.action, decision.reason)
return None
# The grown instance loads after this one exits, so the model's own memory counts as free.
# Other loaded models still count as held, which errs toward a smaller window.
live = _launch_budget(budget, own_bytes=resident_footprint(gguf, budget, current_window) or 0)
plan = preset_for_model(gguf, budget, set(), requested_window=decision.next_window, live=live)
if plan is None or plan.refusal or plan.window < decision.next_window:
logger.debug("growth %s: the next rung does not fit beside other programs' GPU memory",
model_id)
return None
logger.info("context growth %s: %s", model_id, decision.reason)
save_window_override(model_id, decision.next_window)
if not refresh_local_runtime():
# The override still lands at the next boot; report no growth NOW so the caller
# compresses instead of overflowing a stale window.
logger.warning("growth %s: server refresh failed; compression proceeds", model_id)
return None
materialized = read_preset_decisions().get(model_id)
if materialized is None or materialized.window < decision.next_window:
logger.warning("growth %s: refreshed preset did not grant the requested window", model_id)
return None
return materialized.window