e83816a4d1
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.
285 lines
13 KiB
Python
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
|