Files
hermes-agent/tools/clarify_tool.py
teknium1 b3aef45987 fix(clarify): batch result carries the surface's no-answer notice; trim tests
Follow-up to the #112725 salvage. When a gateway clarify batch stops at an
unanswered question, the batch payload only said `timed_out: true` with blank
answers — so a card the platform REJECTED ("[clarify prompt could not be
delivered]") read exactly like a user who walked away, which is the misreport
#112684 describes ("a timeout must not be presented as user inactivity when the
prompt was never delivered").

- gateway/run_turn_runner.py::_clarify_batch_sync: the unanswered question's
  own response text rides along as `notice`.
- tools/clarify_tool.py::_run_batch/_batch_result: pass `notice` through into
  the result JSON beside `timed_out` (only when present); schema description
  mentions it. CLI/TUI batch callbacks send no notice -> byte-identical output.
- tests: fold the single-question re-arm pin into the existing bracket-answer
  test (<=2 new tests), parametrize the end-to-end test over an undeliverable
  Telegram-shaped adapter so the delivery sentinel is pinned as `notice`.
- docs: tools-reference.md describes `notice`.

Part of #112684
2026-09-16 17:47:50 -07:00

319 lines
15 KiB
Python

"""Clarify tool: structured multiple-choice / open-ended questions to the user.
Schema, validation and a thin dispatcher; the UI lives in a platform-provided
callback (cli.py, gateway/run.py, tui_gateway)."""
import inspect
import json
from typing import Dict, List, Optional, Callable
MAX_CHOICES = 4 # the UI always appends an "Other (type your answer)" row
MAX_QUESTIONS = 5 # independent questions per batch call
# Canonical timeout sentinel. The CLI returns this exact text; the batch loop
# treats it (like ``None``) as "the user walked away" and aborts remaining questions.
TIMEOUT_RESPONSE = ("The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed.")
# Applied to the first choice here (not per-surface) so every adapter renders it identically.
RECOMMENDED_LABEL = "(Recommended)"
_UNAVAILABLE = "Clarify tool is not available in this execution context."
def _flatten_choice(c) -> str:
"""Coerce one choice to display text. LLMs sometimes emit dict-shaped choices and ``str(c)``
would leak the repr onto every surface and back as the answer; unwrap order ``label`` >
``description`` > ``text`` > ``title`` (``name``/``value`` excluded: raw component enums,
not labels). No match -> "" and dropped: no choice beats a garbage label."""
if isinstance(c, str):
return c.strip()
if isinstance(c, dict):
return next((v.strip() for k in ("label", "description", "text", "title")
if isinstance(v := c.get(k), str) and v.strip()), "")
if isinstance(c, (list, tuple)):
return " ".join(_flatten_choice(x) for x in c).strip()
return "" if c is None else str(c).strip()
def mark_recommended(choices: List[str]) -> List[str]:
"""Suffix the first choice (schema says best-first) with RECOMMENDED_LABEL; idempotent,
and a lone choice is left untouched (nothing to prefer it over)."""
first = str(choices[0]).strip() if choices else ""
if len(choices) < 2 or first != strip_recommended(first):
return choices
return [f"{first} {RECOMMENDED_LABEL}"] + list(choices[1:])
def strip_recommended(text: str) -> str:
"""Remove the recommendation label so presentation never leaks into ``user_response``."""
stripped = str(text).strip()
if stripped.casefold().endswith(RECOMMENDED_LABEL.casefold()):
return stripped[: -len(RECOMMENDED_LABEL)].strip()
return stripped
def _accepts_kwarg(callback, name: str) -> bool:
"""Signature-inspect (never a TypeError retry, which could re-prompt the user) whether
``callback`` takes ``name`` or ``**kwargs``; non-introspectable callables are legacy."""
try:
params = inspect.signature(callback).parameters
except (TypeError, ValueError):
return False
return name in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
def _invoke_callback(callback, question, choices, multi_select):
"""Invoke the platform callback, passing multi_select if supported."""
if _accepts_kwarg(callback, "multi_select"):
return callback(question, choices, multi_select=multi_select)
return callback(question, choices)
def _json_as(raw: str, kind):
"""``json.loads(raw)`` when it decodes to an instance of ``kind``, else None."""
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, kind) else None
def _parse_multi_select_response(raw_response) -> List[str]:
"""Parse a list / JSON array / comma-separated reply into stripped non-empty strings."""
items = raw_response
if not isinstance(items, list):
raw = str(items).strip()
items = _json_as(raw, list) if raw.startswith("[") else None
if items is None:
items = raw.split(",")
return [str(r).strip() for r in items if str(r).strip()]
def _clean_answer(raw, multi: bool):
"""Strip presentation (the label, multi-select JSON) from a locked answer."""
return [strip_recommended(r) for r in _parse_multi_select_response(raw)] if multi else strip_recommended(raw)
def _clean_choices(choices: list) -> Optional[List[str]]:
"""Flatten, drop empties, cap at MAX_CHOICES; None when nothing survives (open-ended)."""
cleaned = [s for s in (_flatten_choice(c) for c in choices) if s]
return cleaned[:MAX_CHOICES] or None
def _is_timeout(raw) -> bool:
return raw is None or (isinstance(raw, str) and raw.strip() == TIMEOUT_RESPONSE)
# ============================================================================= Batch (multi-question)
# support — issue #18450 =============================================================================
def _normalize_questions(questions) -> tuple:
"""Validate the ``questions`` batch param -> ``(normalized, error)``; an empty list gives
``(None, None)`` (fall back to the single-question path). Entries carry ``qid`` (stable
wire id ``q<index>`` surfaces key answers by; the model's ``id`` is unvalidated text, only
echoed), ``question``, decorated ``choices``, bare ``choices_offered``, ``multi_select``."""
if not isinstance(questions, list):
return None, "questions must be an array of question objects."
if not questions:
return None, None
if len(questions) > MAX_QUESTIONS:
return None, f"questions supports at most {MAX_QUESTIONS} items."
normalized = []
for index, item in enumerate(questions):
if isinstance(item, str): # tolerate bare-string items: LLMs sometimes send ["Q1?", "Q2?"]
item = {"question": item}
if not isinstance(item, dict):
return None, f"questions[{index}] must be an object with a 'question'."
text = str(item.get("question") or "").strip()
if not text:
return None, f"questions[{index}].question must be non-empty text."
choices = item.get("choices")
if choices is not None:
if not isinstance(choices, list):
return None, f"questions[{index}].choices must be a list."
choices = _clean_choices(choices)
normalized.append({
"qid": f"q{index}", "id": str(item.get("id") or "").strip() or None, "question": text,
"choices": mark_recommended(list(choices)) if choices else None,
"choices_offered": list(choices) if choices else None,
"multi_select": bool(item.get("multi_select")) and bool(choices)})
return normalized, None
def _batch_result(normalized: List[dict], answers: dict, timed_out: bool, notice: Optional[str] = None) -> str:
"""Batch result JSON; unanswered -> "". The top-level ``timed_out`` flag (present only when
true) tells the agent whether blanks are deliberate skips or the user walking away; ``notice``
(surface-supplied, only beside ``timed_out``) says WHY the wait ended, so an undeliverable
prompt never reads as user inactivity."""
responses = []
for entry in normalized:
raw = answers.get(entry["qid"])
responses.append({
**({"id": entry["id"]} if entry["id"] else {}),
"question": entry["question"], "choices_offered": entry["choices_offered"],
"user_response": _clean_answer(raw, entry["multi_select"]) if raw else ""})
result: Dict[str, object] = {"responses": responses}
if timed_out:
result["timed_out"] = True
if notice:
result["notice"] = str(notice)
return json.dumps(result, ensure_ascii=False)
def _run_batch(normalized: List[dict], callback, question: str) -> str:
"""Dispatch a validated batch. Batch-capable callbacks (``questions`` kwarg) get the
whole list once and reply ``{"answers": {qid: raw}, "timed_out"?}`` as a dict or JSON
string (the tui_gateway bridge only carries strings); any other falsy/unparseable reply
is a cancel-all (mirrors the single-question skip). Legacy callbacks are looped per
question: an empty answer is a skip, a timeout (``None`` or the sentinel) means the user
walked away so the loop aborts instead of pestering them; earlier answers are kept."""
answers: dict = {}
timed_out = False
notice = None
if _accepts_kwarg(callback, "questions"):
raw = callback(question, None, questions=normalized)
timed_out = _is_timeout(raw)
if isinstance(raw, str):
raw = _json_as(raw, dict) # the sentinel is not JSON -> None, timed_out stays True
if isinstance(raw, dict):
answers = dict(raw.get("answers") or {})
timed_out = bool(raw.get("timed_out"))
notice = raw.get("notice")
return _batch_result(normalized, answers, timed_out, notice)
for entry in normalized:
raw = _invoke_callback(callback, entry["question"], entry["choices"], entry["multi_select"])
if _is_timeout(raw):
timed_out = True
break
answers[entry["qid"]] = raw
return _batch_result(normalized, answers, timed_out)
def clarify_tool(question: str, choices: Optional[List[str]] = None, multi_select: bool = False,
questions: Optional[List[dict]] = None, callback: Optional[Callable] = None) -> str:
"""Ask one question (``question``/``choices``/``multi_select``) or a batch (``questions``
wins when non-empty). ``callback(question, choices, multi_select=False) -> str`` is
platform injected (batch-capable ones also take ``questions=``). Returns result JSON.
Args: question: The question text to present. choices: Up to 4 predefined answer choices. When
omitted the question is purely open-ended. multi_select: When True, the user can select multiple choices
(checkboxes). The ``user_response`` in the output JSON will be a list of strings instead of a single
string. Has no effect when ``choices`` is omitted. questions: Up to 5 independent questions asked as
one batch (issue #18450). When present (non-empty), the single ``question``/``choices``/``multi_select``
parameters are ignored and the result JSON is ``{"responses": [...]}`` (plus ``"timed_out": true`` when
the user stopped answering partway). callback: Platform-provided function that handles the actual UI
interaction. Batch-capable platforms additionally accept a ``questions`` keyword and receive the
normalized list in one call; platforms without it are looped one question at a time. Injected by the
agent runner (cli.py / gateway).
"""
if questions is not None:
normalized, error = _normalize_questions(questions)
if error:
return tool_error(error)
if normalized:
if callback is None:
return tool_error(_UNAVAILABLE)
try:
return _run_batch(normalized, callback, str(question or "").strip())
except Exception as exc:
return tool_error(f"Failed to get user input: {exc}")
# Empty questions array → fall through to the single-question path.
if not question or not question.strip():
return tool_error("No question provided. Pass questions=[{question: '...', "
"choices?: [...], multi_select?: bool}, ...] — a single question "
"is a one-entry array.")
question = question.strip()
if choices is not None:
if not isinstance(choices, list):
return tool_error("choices must be a list of strings.")
choices = _clean_choices(choices)
if callback is None:
return tool_error(_UNAVAILABLE)
# The bare list goes back to the agent; the "(Recommended)" label is presentation only.
shown = mark_recommended(choices) if choices is not None else None
try:
raw_response = _invoke_callback(callback, question, shown, multi_select)
except Exception as exc:
return tool_error(f"Failed to get user input: {exc}")
return json.dumps({"question": question, "choices_offered": choices,
"user_response": _clean_answer(raw_response, multi_select and choices is not None)},
ensure_ascii=False)
def check_clarify_requirements() -> bool:
"""Clarify tool has no external requirements -- always available."""
return True
CLARIFY_SCHEMA = {
"name": "clarify",
"description": (
"Ask the user one or more questions when you need a decision, "
"clarification, or feedback before proceeding. Pass every question "
f"in `questions` (1-{MAX_QUESTIONS} entries) — a single question is a "
"one-entry array, and several INDEPENDENT questions belong in ONE "
"call (one form beats a chain of clarify calls; if one answer would "
"change another question, ask separately). Per question: "
f"single-select (up to {MAX_CHOICES} choices — put your recommended "
"option FIRST, the UI marks it '(Recommended)' and auto-appends an "
"'Other' free-text row), multi-select (multi_select=true), or "
"open-ended (omit choices). Options go ONLY in `choices`, never "
"enumerated inside the question text (choices render as pickable "
"rows; options written into the question are dead prose the user "
"can't click). Result: {responses: [...]} in question order (plus "
"timed_out=true, and a notice saying why, if the user stopped "
"part-way or the prompt could not be delivered). Prefer deciding "
"low-stakes questions yourself; don't use this for dangerous-command "
"confirmation (the terminal tool handles that)."
),
"parameters": {
"type": "object",
"properties": {
"questions": {
"type": "array",
"minItems": 1,
"maxItems": MAX_QUESTIONS,
"description": (
"The question(s). Each: question text (options excluded), "
"optional choices (recommended first; omit for free-text), "
"optional multi_select. Responses come back in question "
"order with the question text echoed."
),
"items": {
"type": "object",
"properties": {
"question": {"type": "string"},
"choices": {
"type": "array",
"items": {"type": "string"},
"maxItems": MAX_CHOICES,
},
"multi_select": {"type": "boolean"},
},
"required": ["question"],
},
},
# NOTE: the handler also accepts (unadvertised): a per-question
# `id` (echoed in the matching response — redundant since rows
# carry the question text and preserve order), and the legacy
# single-question shape (`question` + `choices` + `multi_select`
# at top level; a top-level `question` beside `questions` is the
# batch form's title). One documented way to call.
},
"required": ["questions"],
},
}
# --- Registry ---
from tools.registry import registry, tool_error
registry.register(
name="clarify",
toolset="clarify",
schema=CLARIFY_SCHEMA,
handler=lambda args, **kw: clarify_tool(
question=args.get("question", ""),
choices=args.get("choices"),
multi_select=args.get("multi_select", False),
questions=args.get("questions"),
callback=kw.get("callback")),
check_fn=check_clarify_requirements,
emoji="❓",
)