mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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)
124 lines
4.8 KiB
Python
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
|