Files
hermes-agent/agent/local_prompt_progress.py
emozilla 4bc2efbeb3 fix(local): keep long local-model turns alive on laptops
A press reviewer ran Qwen 27B on a 64 GB RTX Spark and saw single queries take hours. The
causes, and what each part of this change does about them:

- llama-server reads a long prompt for minutes before the first token. Local requests now
  always stream and ask for prompt-reading progress, so the stall timers see the server
  working and the router notices a hang-up within seconds. They no longer fall back to a
  non-streamed request, which the router can't cancel. The status line shows how much of
  the prompt the server has read.
- A laptop that sleeps mid-request froze the request, and the stall timer cut it on wake.
  Stall timers for a server on the same machine now skip time the process was stopped
  (sleep or Modern Standby). Background-call watchdogs run on the awake clock.
- After repeated stalls the breaker failed every later message at once, even after the local
  server recovered. At turn start, a local server that answers its health check gets one more
  attempt; one more stall shuts the breaker again.
- On Windows unified memory, the GPU carve-out is invisible to Windows, and the model plus
  llama-server's default 8 GiB prompt cache overran commit. Free commit now narrows the launch
  window (never refusing a model the pool can hold), caps how many models the router holds at
  once, and sizes the prompt cache, split across those models.
- Hermes's status reads named the model, and the router loads any model a request names, so
  Eject and the idle unload reloaded the model at once. Status reads pass autoload=false; an
  unloaded model counts as idle, and the context probe reads the router's launch args.
- The smart-approval verdict has a 16-token budget, and a thinking model spent all of it
  thinking (45 of 45 verdicts came back empty). The verdict asks for no thinking unless the
  operator set auxiliary.approval.reasoning_effort.
- Compression's 600 s limit counted the time the server spent reading a 150K-token
  conversation, which alone can exceed it on a laptop GPU. Prompt reading now pushes the fence
  deadline back (CompressionCommitFence.extend_deadline), only while the server reports newly read
  tokens, and never past a fence the host closed or one a commit holds. The agent and gateway
  hygiene waits read the moved deadline after every slice.
- Desktop keep-awake defaults to "while working". When a timed sleep interrupts a turn with
  keep-awake off, the next wake offers it once per computer; the offer survives the next prompt.

(cherry picked from commit 518ce272b9)
2026-10-16 15:21:07 -04:00

124 lines
4.8 KiB
Python

"""Prompt-reading progress from llama-server.
llama-server reads a long prompt for minutes before it writes the first token. Asked with
``return_progress``, it streams a progress chunk after each batch it reads. Hermes uses those
chunks as signs of life: the stall timers stay quiet while the server works, and the router
notices a client hang-up within seconds (it only checks the client socket when it writes).
Compression also leaves the time the server spends reading the prompt out of its total time
limit (``conversation_compression_deadline``).
"""
from __future__ import annotations
import contextlib
import threading
import time
from typing import Any, Callable, Optional
_reading = threading.local()
_CONTENT_FIELDS = ("content", "tool_calls", "reasoning_content", "reasoning", "refusal")
def with_prompt_progress(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Copy of chat-completions *kwargs* that asks llama-server for prompt progress chunks."""
extra_body = kwargs.get("extra_body")
merged = dict(extra_body) if isinstance(extra_body, dict) else {}
merged.setdefault("return_progress", True)
return {**kwargs, "extra_body": merged}
def prompt_progress(chunk: Any) -> Optional[dict]:
"""The ``prompt_progress`` payload of a progress-only chunk, else None.
A progress chunk carries an empty assistant delta. A chunk with text, tool calls, reasoning or a
finish reason is a real chunk even if a server attaches progress to it.
"""
extra = getattr(chunk, "model_extra", None)
progress = extra.get("prompt_progress") if isinstance(extra, dict) else None
if progress is None and isinstance(chunk, dict):
progress = chunk.get("prompt_progress")
if not isinstance(progress, dict):
return None
choices = getattr(chunk, "choices", None)
if choices is None and isinstance(chunk, dict):
choices = chunk.get("choices")
for choice in choices or []:
finish = choice.get("finish_reason") if isinstance(choice, dict) else getattr(choice, "finish_reason", None)
delta = choice.get("delta") if isinstance(choice, dict) else getattr(choice, "delta", None)
if finish:
return None
for field in _CONTENT_FIELDS:
value = delta.get(field) if isinstance(delta, dict) else getattr(delta, field, None)
if value:
return None
return progress
def _count(progress: dict, key: str) -> Optional[int]:
try:
return int(progress[key])
except (KeyError, TypeError, ValueError):
return None
def prompt_progress_percent(progress: dict) -> Optional[int]:
"""Share of the prompt the server has read, 0-100, or None without a usable total."""
try:
total = int(progress.get("total") or 0)
processed = int(progress.get("processed") or 0)
except (TypeError, ValueError):
return None
if total <= 0:
return None
return max(0, min(100, round(processed / total * 100)))
@contextlib.contextmanager
def prompt_reading_hook(hook: Optional[Callable[[float], None]]):
"""Install ``hook(seconds)`` for this thread; it hears each stretch of prompt reading."""
previous = getattr(_reading, "hook", None)
_reading.hook = hook
try:
yield
finally:
_reading.hook = previous
def current_prompt_reading_hook() -> Optional[Callable[[float], None]]:
"""This thread's prompt-reading hook (carried into provider daemon threads)."""
return getattr(_reading, "hook", None)
class PromptReadingClock:
"""Time llama-server spends reading the prompt.
Counted per progress chunk, and only when the server reports more tokens read than before: a
server that keeps reporting without reading anything gets no extra time. Each stretch is the
smaller of the wall time since the previous chunk and the server's own reading time, so time
spent queued or loading the model before the reading starts doesn't count.
"""
def __init__(self, started: float):
self._mark = started
self._processed = -1
self._server_ms = 0
self.seconds = 0.0
def tick(self, progress: dict) -> bool:
"""Count the reading up to this progress chunk; False when it reports nothing new read."""
now = time.monotonic()
wall, self._mark = now - self._mark, now
processed, server_ms = _count(progress, "processed"), _count(progress, "time_ms")
if processed is None or processed <= self._processed:
return False
server = wall if server_ms is None else (server_ms - self._server_ms) / 1000
self._processed, self._server_ms = processed, self._server_ms if server_ms is None else server_ms
spent = max(0.0, min(wall, server))
self.seconds += spent
hook = current_prompt_reading_hook()
if hook is not None:
hook(spent)
return True