Files
hermes-agent/gateway/session_llm_oneshot.py
unsupportedpastels 43b6acbe37 fix(gateway): an owner stop drops a one-shot result not yet written
f34b0681b436 made an owner stop answer each outstanding one-shot as
runtime_draining, but _answer only refused when the call had not
finished. A running call whose thread returned after the stop, but
before its answer task woke, still sent its late result: both the reply
and the stop event were done when the task ran, and the reply won.

The stop now wins whenever it is set by the time the answer task
wakes, so every request outstanding at the stop gets exactly one
runtime_draining refusal and no late result. A dropped failure is
retrieved so asyncio does not log it as never retrieved.

Refs #134889 (RT-7)
Refs #140208
2026-10-17 13:31:15 -04:00

263 lines
13 KiB
Python

"""``llm.oneshot`` on the shared gateway: a session-bound one-shot borrows that session's model.
Main's sidecar looked the session up in its own table and lent its live agent's route
(``tui_gateway/session_registry._main_runtime_from_agent``); a session-less call (project ideas)
ran on the ``task`` auxiliary backend. The sidecar's table never holds an owner session, so on the
shared gateway every Desktop one-shot fell through to the task backend. The owner answers it here:
the resident agent's route, else the session's own frozen launch route.
The model call never runs on the connection's serial dispatch: like the sidecar's long-handler
pool, the reply is written by a detached task, so a slow commit message does not hold up the next
request on that socket. Nor does it run on the loop's default executor, which session resume,
socket setup and disconnect cleanup share across every connection and profile: each owner has its
own small pool, and a call past its bound is refused by name instead of queueing without limit.
"""
import asyncio
from concurrent.futures import Future
from contextvars import copy_context
from functools import partial
import logging
import threading
from gateway.session_busy_controls import authorize
from hermes_state_runtime import RuntimeStoreError
logger = logging.getLogger(__name__)
_FIELDS = frozenset({'session_id', 'profile', 'template', 'instructions', 'input', 'variables', 'task',
'temperature', 'max_tokens'})
_TEXT = ('template', 'instructions', 'input', 'task')
# Per owner: model calls running at once, and calls admitted in all (running + queued). A call holds
# its slot until its thread returns, even after its socket closed.
_WORKERS = 4
_ADMITTED = 16
def handlers(connection):
return {'llm.oneshot': partial(oneshot, connection)}
class _Pool:
"""One owner's one-shot threads, its admitted calls, and the answer task of each per connection.
Its own FIFO, not a ThreadPoolExecutor: an executor keeps a cancelled queued call (its params and
copied context) until a worker dequeues it, so with every worker parked on the model a reconnect
loop would pile up payloads past the bound. Here a cancelled call leaves the queue at once."""
def __init__(self):
self.lock = threading.Lock() # ``calls``, ``queue`` and ``threads`` are shared with the workers
self.calls = set() # admitted calls, queued or running: at most _ADMITTED
self.queue = {} # queued call -> its work, oldest first
self.threads = 0 # worker threads alive: at most _WORKERS
self.answers = {} # answer task -> connection
self.closed = False
self.stopping = asyncio.Event() # set on the loop by ``stop``: each answer refuses as draining
def submit(self, work):
"""Admit ``work`` (or refuse it: ``runtime_draining``, ``oneshot_busy``) and return its Future."""
call = Future()
with self.lock:
if self.closed:
raise RuntimeStoreError('runtime_draining')
if len(self.calls) >= _ADMITTED:
raise RuntimeStoreError('oneshot_busy', detail=f'{_ADMITTED} one-shot generations already in flight')
self.calls.add(call)
self.queue[call] = work
spawn = self.threads < _WORKERS
self.threads += spawn
call.add_done_callback(self.release)
if spawn:
# Daemon: a call stuck on its model must not hold up interpreter exit (it writes no state).
from agent.memory_provider import spawn_context_thread
try:
spawn_context_thread(self.drain, name='hermes-oneshot').start()
except RuntimeError:
# No new worker (thread exhaustion). A live worker drains this call; with none it
# would never run, so it leaves the queue with its slot and the error goes out.
with self.lock:
self.threads -= 1
orphaned = not self.threads
if orphaned and call.cancel():
raise
return call
def release(self, call):
"""The call finished, or was cancelled while queued: its slot and its queued work go now."""
with self.lock:
self.calls.discard(call)
self.queue.pop(call, None)
def drain(self):
while True:
with self.lock:
if not self.queue:
self.threads -= 1
return
call = next(iter(self.queue))
work = self.queue.pop(call)
if call.set_running_or_notify_cancel():
try:
call.set_result(work())
except BaseException as exc: # health: allow BLE001 -- the Future hands every failure to _answer, which logs it with exc_info
call.set_exception(exc)
del call, work # a finished call's payload is not kept while this thread waits for more
def _pool(authority) -> _Pool:
pool = getattr(authority, '_oneshot_pool', None)
if not isinstance(pool, _Pool):
pool = authority._oneshot_pool = _Pool()
return pool
def forget(connection):
"""The socket closed: drop its queued calls and answers. A call already running in its thread
cannot be interrupted; it finishes, keeps its slot until then, and its reply is discarded."""
pool = getattr(connection.authority, '_oneshot_pool', None)
for task, owner in list(pool.answers.items()) if isinstance(pool, _Pool) else ():
if owner is connection:
task.cancel()
def stop(authority):
"""Owner shutdown (whole runtime or one profile's unserve), on the loop: refuse new calls, cancel
every queued call, and answer each outstanding request once with ``runtime_draining``. One
profile's unserve leaves the host socket open for the others, so unlike ``forget`` its requests
still get a reply. Running calls finish on their own (no SessionDB writes, only the model reply)
and that reply is dropped."""
pool = _pool(authority) # an owner that never served one still refuses from now on
with pool.lock:
pool.closed = True
queued = list(pool.queue)
pool.stopping.set()
for call in queued:
call.cancel()
class DeferredReply:
"""A handler result answered later: ``dispatch`` hands over the request id and sends no frame."""
def __init__(self, work):
self.work = work # blocking callable -> result dict; runs on the owner's one-shot pool
def start(self, connection, rid, check=None):
"""Admit the call (or refuse it: ``oneshot_busy``, ``runtime_draining``) and answer it later;
``check`` is the contract's result check, run before the reply is written."""
pool = _pool(connection.authority)
# The handler's context (profile scope, secret scope) carries into the thread.
call = pool.submit(partial(copy_context().run, self.work))
task = asyncio.get_running_loop().create_task(_answer(pool, connection.transport, rid, call, check))
pool.answers[task] = connection
def settled(done):
pool.answers.pop(done, None)
if done.cancelled():
call.cancel() # still queued: leaves the queue, never runs (also before its first step)
task.add_done_callback(settled)
async def _answer(pool, transport, rid, call, check):
"""Write the request's one reply: the call's result or failure, or ``runtime_draining`` once the
owner stops first. Its socket closing cancels this task instead, and then no frame goes out."""
reply = asyncio.wrap_future(call)
stopping = asyncio.ensure_future(pool.stopping.wait())
try:
await asyncio.wait((reply, stopping), return_when=asyncio.FIRST_COMPLETED)
except asyncio.CancelledError: # the socket closed (``forget``): nobody reads a reply
reply.cancel()
raise
finally:
stopping.cancel()
if pool.stopping.is_set() or not reply.done() or reply.cancelled():
# The owner stopped first: a queued call was cancelled, a running call's late result (also one
# that arrived while this task waited to wake) is dropped, its failure consumed unlogged.
if not reply.cancel() and not reply.cancelled():
reply.exception()
frame = {'jsonrpc': '2.0', 'id': rid, 'error': {
'code': 4001, 'message': 'runtime_draining', 'data': {'reason': 'runtime_draining'}}}
else:
try:
result = reply.result()
if check is not None:
check(result)
frame = {'jsonrpc': '2.0', 'id': rid, 'result': result}
except Exception as exc: # the request's only answer: every failure must reach the client
# The sidecar's own failure code and text (tui_gateway/methods_session.py).
logger.warning('llm.oneshot failed: %s', exc, exc_info=True)
frame = {'jsonrpc': '2.0', 'id': rid,
'error': {'code': 5030, 'message': f'one-shot generation failed: {exc}'}}
await transport.write_async(frame)
def _validate(params):
from agent.oneshot import PROMPT_TEMPLATES
if (set(params) - _FIELDS or any(key in params and params[key] is not None and not isinstance(params[key], str)
for key in (*_TEXT, 'session_id'))
or not isinstance(params.get('variables') or {}, dict)
or any(key in params and params[key] is not None
and (isinstance(params[key], bool) or not isinstance(params[key], (int, float)))
for key in ('temperature', 'max_tokens'))):
raise RuntimeStoreError('invalid_params')
template = (params.get('template') or '').strip()
if template and template not in PROMPT_TEMPLATES:
raise RuntimeStoreError('invalid_params', field='template', detail='unknown one-shot template')
if not template and not (params.get('instructions') or '').strip() and not (params.get('input') or '').strip():
raise RuntimeStoreError('invalid_params', field='input', detail='needs a template or instructions/input')
def _session_runtime(authority, ref):
"""``main_runtime`` for the auxiliary client: the resident agent's live route (main's sidecar),
snapshotted here on the loop, else the session's own frozen launch route as its next turn
resolves it (read from config in the worker)."""
from tui_gateway.session_registry import _main_runtime_from_agent
agent = authority.agent(ref)
if agent is not None:
runtime = _main_runtime_from_agent(agent)
return lambda: runtime
from gateway.session_policy import policy_for_source
policy = policy_for_source(authority.runner, authority.sessions[ref.session_id].source)
if policy is None:
return lambda: None
def resolve():
from gateway.run_turn_prepare import _resolve_policy_agent_runtime
# fallback=False: only the session's primary route. If it cannot resolve (an AuthError), the
# one-shot fails with 5030 instead of walking the route's fallback chain, because that walk
# sets the runner-wide fallback notice the next turn would report.
model, kwargs = _resolve_policy_agent_runtime(authority.runner, policy, fallback=False)
runtime = {key: kwargs[key] for key in ('provider', 'base_url', 'api_key', 'api_mode') if kwargs.get(key)}
return {**runtime, 'model': model} if model else runtime or None
return resolve
async def oneshot(connection, ref, params):
_validate(params)
authority = connection.authority
if ref.session_id:
authorize(connection, ref, params, 'session:read')
runtime = _session_runtime(authority, ref)
else:
from gateway.session_authorities import require_serving
require_serving(authority)
if 'session:read' not in connection.actor.capabilities:
raise RuntimeStoreError('permission_denied')
if params.get('profile'):
from pathlib import Path
from hermes_cli.profiles import profile_matches_home
if not profile_matches_home(params['profile'], Path(authority.profile_id)):
raise RuntimeStoreError('profile_mismatch')
runtime = lambda: None # the task backend
def work():
from agent.oneshot import run_oneshot
from gateway.session_authorities import owner_scope
with owner_scope(authority):
return {'text': run_oneshot(
instructions=params.get('instructions') or '', user_input=params.get('input') or '',
template=(params.get('template') or '').strip() or None, variables=params.get('variables') or {},
task=(params.get('task') or '').strip() or 'title_generation',
max_tokens=int(params.get('max_tokens') or 1024),
temperature=0.3 if params.get('temperature') is None else float(params['temperature']),
main_runtime=runtime())}
return DeferredReply(work)