mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Review findings on the TurnQueue PR, fixed in one pass: 1. "Send now" on an idle session was a silent no-op: session.queue.promote reordered the queue but never drained it, so the promoted entry (and the drainNextQueued rescue gesture built on it) just sat there. Promote now fires _drain_queued_prompt in a thread when the session is idle, same as an idle session.queue.add. 2. A drained entry whose dispatch raised was lost: _drain_queued_prompt popped the entry and emitted queue.drained (painting a user turn in the client transcript) before _run_prompt_submit. On exception the entry was gone and the transcript lied. The drain now requeues the entry at the head (same id, so client mirrors stay consistent) via the new TurnQueue.requeue_front(), and queue.drained is only emitted after a successful dispatch. 3. Multi-line steers never settled: settlePendingSteer split the applied text into a line-set, so an entry that itself contained newlines (Cmd+Enter on a multi-line draft) matched nothing and pinned a "Steering..." row forever. Now matches by whole-entry containment, plus a message.complete backstop sweep (a steer can't outlive its turn: applied, dropped, or re-queued as the next turn). 4. Speculative surface removed per the contribution rubric: QueuedTurn.mode (written, never read), QueuedTurn.attachments (clients resolve attachments to @file: refs at enqueue time), enqueue_front() (replaced by the requeue_front() that finding 2 actually needs), and the keep_queue param on session.interrupt (documented for a promote+interrupt flow that actually interrupts via agent.interrupt() directly, so it was dead). Also: unused sessionId arg dropped from useComposerQueue, the steer-event lambda no longer shadows the enclosing text parameter, and a rejected session.queue.add now surfaces an i18n'd error toast instead of silently no-oping (draft is kept either way). Tests: idle-promote drains immediately, failed dispatch requeues at head without emitting queue.drained, interrupt clears the queue, multi-line steer settles. 16 gateway tests pass; desktop tsc/eslint/vitest clean.
173 lines
6.2 KiB
Python
173 lines
6.2 KiB
Python
"""TurnQueue — a thread-safe FIFO of prompts to run as the next turn(s).
|
|
|
|
This is the agent-side queue that unifies message queueing across CLI, TUI
|
|
gateway, desktop, and messaging platforms. It lives on the AIAgent instance,
|
|
alongside the existing ``_pending_steer`` mechanism.
|
|
|
|
Unlike steer (which injects into the *current* turn's tool results), queued
|
|
prompts become the *next* user turn after the current one finishes. The
|
|
drain point is the agent-loop / gateway-runner tail — wherever the current
|
|
turn ends and ``running`` flips to False.
|
|
|
|
Thread-safety: all mutations go through ``self._lock``. Safe to call from
|
|
gateway threads, CLI process_loop, and the agent execution thread.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Optional
|
|
|
|
|
|
@dataclass
|
|
class QueuedTurn:
|
|
"""A single queued prompt entry."""
|
|
|
|
id: str
|
|
text: str
|
|
transport: Any = None # pinned transport for the drained turn
|
|
queued_at: float = field(default_factory=time.time)
|
|
# Where the entry came from: "queue" (explicit session.queue.add — the
|
|
# client shows it in a queue panel, not the transcript) or "busy_submit"
|
|
# (a prompt.submit that landed mid-turn — the client already echoed it
|
|
# as an optimistic user message). Lets drain events tell clients whether
|
|
# the text still needs painting.
|
|
source: str = "queue"
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Serialise for RPC / event emission (no transport internals)."""
|
|
return {
|
|
"id": self.id,
|
|
"text": self.text,
|
|
"queued_at": self.queued_at,
|
|
"source": self.source,
|
|
}
|
|
|
|
|
|
class TurnQueue:
|
|
"""Thread-safe FIFO of prompts waiting to become the next turn.
|
|
|
|
Replaces the ad-hoc ``session["queued_prompt"]`` dict slot in the TUI
|
|
gateway and the ``localStorage``-backed queue in the desktop renderer.
|
|
The queue lives in the agent process so it drains even when no client
|
|
window is open.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._entries: list[QueuedTurn] = []
|
|
self._lock = threading.Lock()
|
|
|
|
# ── enqueue ──────────────────────────────────────────────
|
|
|
|
def enqueue(
|
|
self,
|
|
text: str,
|
|
transport: Any = None,
|
|
source: str = "queue",
|
|
) -> QueuedTurn:
|
|
"""Add a prompt to the back of the queue.
|
|
|
|
Consecutive text entries are *not* merged here — the gateway's
|
|
``_handle_busy_submit`` still does merge if it wants to (mirroring
|
|
``repair_message_sequence``). Callers that want merge semantics
|
|
should check ``peek()`` first.
|
|
|
|
Returns the created :class:`QueuedTurn`.
|
|
"""
|
|
entry = QueuedTurn(
|
|
id=str(uuid.uuid4()),
|
|
text=text,
|
|
transport=transport,
|
|
source=source,
|
|
)
|
|
with self._lock:
|
|
self._entries.append(entry)
|
|
return entry
|
|
|
|
def requeue_front(self, entry: QueuedTurn) -> QueuedTurn:
|
|
"""Put a drained entry back at the *front* of the queue.
|
|
|
|
Used by the gateway when the turn dispatch for a drained entry raises
|
|
— the pop already happened, so the entry (same id, so client mirrors
|
|
stay consistent) is restored to the head for the next drain attempt
|
|
instead of being silently lost.
|
|
"""
|
|
with self._lock:
|
|
self._entries.insert(0, entry)
|
|
return entry
|
|
|
|
# ── drain ────────────────────────────────────────────────
|
|
|
|
def drain(self) -> Optional[QueuedTurn]:
|
|
"""Pop and return the head entry, or None if the queue is empty."""
|
|
with self._lock:
|
|
if not self._entries:
|
|
return None
|
|
return self._entries.pop(0)
|
|
|
|
# ── peek / inspect ───────────────────────────────────────
|
|
|
|
def peek(self) -> list[QueuedTurn]:
|
|
"""Return a snapshot copy of pending entries (for UI sync)."""
|
|
with self._lock:
|
|
return list(self._entries)
|
|
|
|
def is_empty(self) -> bool:
|
|
with self._lock:
|
|
return len(self._entries) == 0
|
|
|
|
def __len__(self) -> int:
|
|
with self._lock:
|
|
return len(self._entries)
|
|
|
|
def __bool__(self) -> bool:
|
|
return len(self) > 0
|
|
|
|
# ── mutate ───────────────────────────────────────────────
|
|
|
|
def remove(self, entry_id: str) -> bool:
|
|
"""Remove a specific entry by id. Returns True if found."""
|
|
with self._lock:
|
|
before = len(self._entries)
|
|
self._entries = [e for e in self._entries if e.id != entry_id]
|
|
return len(self._entries) < before
|
|
|
|
def promote(self, entry_id: str) -> bool:
|
|
"""Move an entry to the front of the queue. Returns True if found."""
|
|
with self._lock:
|
|
idx = None
|
|
for i, e in enumerate(self._entries):
|
|
if e.id == entry_id:
|
|
idx = i
|
|
break
|
|
if idx is None or idx == 0:
|
|
return False
|
|
entry = self._entries.pop(idx)
|
|
self._entries.insert(0, entry)
|
|
return True
|
|
|
|
def update_text(self, entry_id: str, text: str) -> bool:
|
|
"""Update the text of a queued entry. Returns True if found."""
|
|
with self._lock:
|
|
for e in self._entries:
|
|
if e.id == entry_id:
|
|
e.text = text
|
|
return True
|
|
return False
|
|
|
|
def clear(self) -> int:
|
|
"""Clear all entries. Returns the number removed."""
|
|
with self._lock:
|
|
n = len(self._entries)
|
|
self._entries.clear()
|
|
return n
|
|
|
|
# ── serialisation ────────────────────────────────────────
|
|
|
|
def to_list(self) -> list[dict]:
|
|
"""Return a list of plain dicts for RPC / event emission."""
|
|
with self._lock:
|
|
return [e.to_dict() for e in self._entries]
|