Files
hermes-agent/tools/clarify_gateway.py
T
Teknium e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00

285 lines
13 KiB
Python

"""Gateway-side clarify primitive (blocking event-based queue). The agent runs on a worker
thread while the event loop handles the user's reply, so a pending clarify is stored
module-level (same shape as ``tools.approval``) and the agent thread blocks on an ``Event``
until an adapter button callback or the gateway text-intercept resolves it, or the timeout
fires. Adapters render inline buttons (an "Other" row flips the entry into text-capture
mode) or a numbered-list text fallback."""
from __future__ import annotations
import json
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class _ClarifyEntry:
"""One pending clarify request inside a gateway session."""
clarify_id: str
session_key: str
question: str
choices: Optional[List[str]]
multi_select: bool = False
event: threading.Event = field(default_factory=threading.Event)
response: Optional[str] = None
awaiting_text: bool = False # set when user picked "Other" or clarify is open-ended
_lock = threading.RLock()
_entries: Dict[str, _ClarifyEntry] = {} # clarify_id -> entry (button callbacks)
_session_index: Dict[str, List[str]] = {} # session_key -> [clarify_id] FIFO (text intercept, cleanup)
# Per-session notify callbacks (gateway -> adapter bridge); mirrors tools.approval. Tests clear it.
_notify_cbs: Dict[str, Callable[[_ClarifyEntry], None]] = {}
# Outcomes for typed clarify replies. Gateway cancels the pending prompt on
# free prose (deadlock break) but keeps it armed for a retryable bad selection.
TEXT_RESOLVED = "resolved"
TEXT_REJECTED_PROSE = "rejected_prose"
TEXT_REJECTED_SELECTION = "rejected_selection"
TEXT_NO_PENDING = "no_pending"
def register(clarify_id: str, session_key: str, question: str, choices: Optional[List[str]],
multi_select: bool = False) -> _ClarifyEntry:
"""Register a pending clarify request; caller then blocks on ``wait_for_response``.
Open-ended (no choices) entries start in text mode: the next message IS the response."""
entry = _ClarifyEntry(clarify_id, session_key, question, list(choices) if choices else None,
bool(multi_select) and bool(choices), awaiting_text=not bool(choices))
with _lock:
_entries[clarify_id] = entry
_session_index.setdefault(session_key, []).append(clarify_id)
return entry
def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]:
"""Block until the entry resolves or ``timeout`` (``<= 0`` = unlimited) elapses; None on
timeout/unknown id. Polls in 1s slices so the inactivity heartbeat keeps firing (a
single long ``Event.wait`` would let the gateway watchdog kill a live prompt)."""
with _lock:
entry = _entries.get(clarify_id)
if entry is None:
return None
try:
from tools.environments.base import touch_activity_if_due
except Exception: # pragma: no cover - optional
touch_activity_if_due = None
deadline = None if timeout is None or float(timeout) <= 0.0 else time.monotonic() + float(timeout)
activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()}
while True:
remaining = 1.0 if deadline is None else deadline - time.monotonic()
if remaining <= 0 or entry.event.wait(timeout=min(1.0, remaining)):
break
# Periodic activity touch so the gateway's inactivity timeout doesn't kill the agent during long
# code execution (#10807).
if touch_activity_if_due is not None:
touch_activity_if_due(activity_state, "waiting for user clarify response")
with _lock:
_entries.pop(clarify_id, None) # regardless of outcome
ids = _session_index.get(entry.session_key) or []
if clarify_id in ids:
ids.remove(clarify_id)
if not ids:
_session_index.pop(entry.session_key, None)
return entry.response
def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
"""Unblock the waiter on ``clarify_id``; False if already resolved/expired/unknown."""
with _lock:
entry = _entries.get(clarify_id)
if entry is None or entry.event.is_set():
return False
entry.response = str(response) if response is not None else ""
entry.event.set()
return True
def get_pending_for_session(session_key: str, *, include_choice_prompts: bool = False) -> Optional[_ClarifyEntry]:
"""Oldest pending entry awaiting free text (open-ended, or after "Other");
``include_choice_prompts=True`` returns the oldest unresolved entry of any kind (user
typed at an active choice prompt: resolve it rather than queue a follow-up turn)."""
with _lock:
for cid in _session_index.get(session_key) or []:
entry = _entries.get(cid)
if entry is not None and (include_choice_prompts or entry.awaiting_text):
return entry
return None
def _match_label(text: str, choices: List[str]) -> Optional[str]:
"""Stripped choice text matching ``text`` case-insensitively, ignoring the '(Recommended)'
suffix the first choice carries by the time it reaches adapters; None if no match."""
from tools.clarify_tool import strip_recommended
wanted = strip_recommended(text).casefold()
for choice in choices:
if strip_recommended(str(choice)).casefold() == wanted:
return str(choice).strip()
return None
def _split_tokens(text: str) -> Optional[List[str]]:
"""Comma-separated tokens, or space-separated all-numeric tokens ("1 3"); else None."""
if "," in text:
return [t.strip() for t in text.split(",") if t.strip()]
parts = text.split()
return parts if len(parts) > 1 and all(p.isdigit() for p in parts) else None
def _is_int(text: str) -> bool:
try:
int(text)
return True
except ValueError:
return False
def _selection_attempt_tokens(text: str, choices: Optional[List[str]] = None) -> Optional[List[str]]:
"""Tokens when ``text`` looks like a typed selection (bare int, comma list,
all-numeric space list); None for free prose so the gateway can release the
clarify. Comma-list labels may span up to the longest choice's word count."""
stripped = str(text).strip()
if not stripped:
return None
tokens = _split_tokens(stripped)
if tokens is None:
digits = stripped[1:] if stripped.startswith("-") else stripped
return [stripped] if digits.isdigit() or _is_int(stripped) else None
if "," not in stripped or not tokens:
return tokens or None
max_words = max(1, max((len(str(c).split()) for c in choices or []), default=1))
return tokens if all(t.isdigit() or len(t.split()) <= max_words for t in tokens) else None
def _coerce_text_response(entry: _ClarifyEntry, response: str) -> Optional[str]:
"""Accepted value for a typed reply, or None on any rejection."""
return _coerce_text_response_detailed(entry, response)[0]
def _coerce_text_response_detailed(entry: _ClarifyEntry, response: str) -> tuple[Optional[str], Optional[str]]:
"""Map a typed reply to ``(value, None)`` or ``(None, reason)``: ``"invalid_selection"``
(selection-shaped but out of range/unrecognised — keep the clarify armed for a retry) or
``"prose"`` (free text on a native choice prompt — the gateway may cancel and route normally
so a redirect-to-steer path cannot deadlock behind the waiting tool). Open-ended entries and
``awaiting_text`` accept any text; numeric picks and exact labels always resolve; multi-select
returns a JSON array string (decoded tool-side); one bad token rejects the whole reply."""
text = str(response).strip()
if not entry.choices:
return text, None
if entry.multi_select:
coerced = _coerce_multi_select_text(entry, text)
selection_shaped = _selection_attempt_tokens(text, entry.choices) is not None
else:
# Out-of-range / non-canonical integer is a failed selection, not prose.
selection_shaped = _is_int(text)
idx = int(text) - 1 if selection_shaped else -1
coerced = entry.choices[idx] if 0 <= idx < len(entry.choices) else _match_label(text, entry.choices)
if coerced is not None:
return coerced, None
if entry.awaiting_text:
return text, None
return None, "invalid_selection" if selection_shaped else "prose"
def _coerce_multi_select_text(entry: _ClarifyEntry, text: str) -> Optional[str]:
"""Parse "1,3" / "1 3" / "staging, prod" into a JSON array of choice labels;
None when any token is out of range or unrecognised (reject the whole reply)."""
choices, selected = entry.choices or [], []
if not text:
return None
tokens = _split_tokens(text)
for token in [text] if tokens is None else tokens:
if token.isdigit():
idx = int(token) - 1
label = str(choices[idx]).strip() if 0 <= idx < len(choices) else None
else:
label = _match_label(token, choices)
if label is None:
return None
if label not in selected:
selected.append(label)
return json.dumps(selected, ensure_ascii=False) if selected else None
def attempt_text_response_for_session(session_key: str, response: str) -> str:
"""Try to resolve the oldest pending clarify from typed text; returns a TEXT_* outcome."""
entry = get_pending_for_session(session_key, include_choice_prompts=True)
if entry is None:
return TEXT_NO_PENDING
coerced, reason = _coerce_text_response_detailed(entry, response)
if coerced is None:
return TEXT_REJECTED_SELECTION if reason == "invalid_selection" else TEXT_REJECTED_PROSE
if resolve_gateway_clarify(entry.clarify_id, coerced):
return TEXT_RESOLVED
return TEXT_NO_PENDING # lost a race with a button/callback resolution — no work left
def resolve_text_response_for_session(session_key: str, response: str) -> bool:
"""True only when the typed reply was accepted and the waiter unblocked."""
return attempt_text_response_for_session(session_key, response) == TEXT_RESOLVED
def mark_awaiting_text(clarify_id: str) -> bool:
"""Flip an entry into text-capture mode (user picked 'Other'); False if unknown."""
with _lock:
entry = _entries.get(clarify_id)
if entry is not None:
entry.awaiting_text = True
return entry is not None
def has_pending(session_key: str) -> bool:
"""True when this session has at least one pending clarify entry."""
with _lock:
return any(_entries.get(cid) is not None for cid in _session_index.get(session_key) or [])
def clear_session(session_key: str) -> int:
"""Drop every pending clarify for a session (``/new``, shutdown, cached-agent eviction) so
blocked agent threads don't outlive it; returns how many were cancelled. Cancelled waiters
see "" (callers tell it from a real reply only via their own timeout bookkeeping; most treat
any falsy result as no response). First-writer-wins: an already-set entry was answered for
real, so it is dropped but its response preserved. The loop stays inside the lock so a button
callback cannot slip between pop and check; entries go regardless of state so a cleared
session is never resurrected by late callbacks."""
with _lock:
cancelled = 0
for entry in (_entries.pop(cid, None) for cid in list(_session_index.pop(session_key, []) or [])):
if entry is None or entry.event.is_set():
continue
entry.response = ""
entry.event.set()
cancelled += 1
return cancelled
def resolve_clarify_timeout(config: dict) -> int:
"""Clarify timeout (seconds): legacy ``clarify.timeout`` if explicitly set, else
``agent.clarify_timeout``, else 3600 — the single source of truth for every surface
(gateway, CLI, TUI). ``<= 0`` is kept verbatim (unlimited); non-numeric -> 3600."""
raw = (config.get("clarify") or {}).get("timeout")
if raw is None:
raw = (config.get("agent") or {}).get("clarify_timeout", 3600)
try:
return int(raw)
except (TypeError, ValueError):
return 3600
def get_clarify_timeout() -> int:
"""Clarify timeout from config.yaml; 0/negative = unlimited. Default 3600: long enough
that a user who stepped away still finds a live entry when they tap, short enough that
an abandoned prompt eventually unblocks the agent thread instead of pinning the guard.
The old 600s default evicted the entry mid-think, so a late tap landed on a dead entry and the agent
hung on ``running: clarify`` (#32762).
"""
try:
from hermes_cli.config import load_config
return resolve_clarify_timeout(load_config() or {})
except Exception:
return 3600