* 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
289 lines
13 KiB
Python
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
|