Files
hermes-agent/tools/clarify_gateway.py
Siddharth Balyan 5eea87882a Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder

Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.

* feat(clarify): one questions[] shape with per-question status and one outcome

The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.

Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.

The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.

* feat(clarify): every surface asks through the one question card

CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.

Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.

Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.

Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.

* fix(clarify): cancelled for a released messaging card, labels win over the skip word

A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.

A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.

The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.

* fix(tui): re-editing a multi-select answer keeps its picks

The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.

Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.

* test(clarify): align three checks with the one-answer confirm and the regenerated keys

The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.

* test(tui): the one-question card header is singular
2026-09-29 18:33:44 +05:30

289 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"
SKIP_WORD = "skip"
SKIPPED = "\x00skipped"
CANCELLED = "\x00cancelled"
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()
is_skip = text.casefold() == SKIP_WORD
if not entry.choices:
return (SKIPPED if is_skip else 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 is_skip:
return SKIPPED, 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. 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 = CANCELLED
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