Files
hermes-agent/tools/clarify_gateway.py
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

309 lines
14 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
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def get_notify(session_key: str) -> Optional[Callable[[_ClarifyEntry], None]]:
with _lock:
return _notify_cbs.get(session_key)
def register_notify(session_key: str, cb: Callable[[_ClarifyEntry], None]) -> None:
"""Register a per-session notify callback used by ``clarify_callback``."""
with _lock:
_notify_cbs[session_key] = cb
def unregister_notify(session_key: str) -> None:
"""Drop the per-session notify callback and cancel any pending clarify entries."""
with _lock:
_notify_cbs.pop(session_key, None)
# Cancel any pending entries so blocked threads unwind when the run
# ends (interrupt, completion, gateway shutdown).
clear_session(session_key)
# ---- END PLUGIN-COMPAT ----