feat(clarify): add multi-select (checkbox) support to clarify tool

Adds a `multi_select` boolean parameter enabling checkbox-style
multi-choice questions (Space to toggle, Enter to confirm).
Backward compatible — defaults to single-select when omitted.

- tools/clarify_tool.py: schema + handler + _parse_multi_select_response
- run_agent.py: both dispatch points pass multi_select
- cli.py: checkbox UI, key bindings, rendering, edge cases
- hermes_cli/callbacks.py: TUI fallback callback
- hermes_cli/oneshot.py: oneshot multi-select message
- tests/tools/test_clarify_tool.py: 12 new tests (35 total)
This commit is contained in:
Ghislain LE MEUR 2026-05-11 14:45:34 +02:00 committed by Teknium
parent bd1db5460a
commit 3e2f91f6b3
5 changed files with 398 additions and 26 deletions

138
cli.py
View file

@ -4460,6 +4460,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._clarify_state = None
self._clarify_freetext = False
self._clarify_deadline = 0
self._clarify_multi_base = None
self._sudo_state = None
self._sudo_deadline = 0
self._modal_input_snapshot = None
@ -11895,7 +11896,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
outcome = outcome[:119] + ""
_cprint(f"\n{_DIM}{icon} {label}: {detail}{outcome}{_RST}")
def _clarify_callback(self, question, choices):
def _clarify_callback(self, question, choices, multi_select=False):
"""
Platform callback for the clarify tool. Called from the agent thread.
@ -11903,6 +11904,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
questions), then blocks until the user responds via the prompt_toolkit
key bindings. If no response arrives within the configured timeout the
question is dismissed and the agent is told to decide on its own.
When ``multi_select`` is True, shows checkboxes and the user can
select multiple options with Space, confirming with Enter.
"""
import time as _time
@ -11913,16 +11917,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
is_open_ended = not choices
# multi-select support: only active when multi_select is True and choices exist
effective_multi = multi_select and not is_open_ended
self._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
# multi-select support
"multi_select": effective_multi,
"selected_indices": set() if effective_multi else None,
"response_queue": response_queue,
}
self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
# Open-ended questions skip straight to freetext input
self._clarify_freetext = is_open_ended
self._clarify_multi_base = None
# Trigger an immediate prompt_toolkit repaint from this (non-main)
# thread. Modal prompts must paint at once and must not be gated by the
@ -11955,6 +11965,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._clarify_state = None
self._clarify_freetext = False
self._clarify_deadline = None
self._clarify_multi_base = None
self._paint_now()
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
@ -12377,6 +12388,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
pass
self._clarify_state = None
self._clarify_freetext = False
self._clarify_multi_base = None
if self._sudo_state:
try:
self._sudo_state["response_queue"].put("")
@ -13926,6 +13938,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if self._clarify_freetext and self._clarify_state:
text = event.app.current_buffer.text.strip()
if text:
# multi-select: prepend previously checked real choices
base = getattr(self, '_clarify_multi_base', None)
if base:
text = ", ".join(base) + ", " + text
self._clarify_multi_base = None
self._clarify_state["response_queue"].put(text)
self._clarify_state = None
self._clarify_freetext = False
@ -13938,6 +13955,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
state = self._clarify_state
selected = state["selected"]
choices = state.get("choices") or []
# multi-select support: submit comma-joined list of checked choices
if state.get("multi_select"):
indices = state.get("selected_indices")
if not indices:
# Nothing checked → submit empty string (parses to [])
state["response_queue"].put("")
self._clarify_state = None
event.app.invalidate()
return
sorted_idx = sorted(indices)
selected_choices = [choices[i] for i in sorted_idx if i < len(choices)]
other_checked = len(choices) in sorted_idx
if other_checked and selected_choices:
# "Other" + real choices: store base choices, switch to freetext
# so the user can type a custom answer that gets appended
self._clarify_multi_base = selected_choices
self._clarify_freetext = True
event.app.invalidate()
return
if selected_choices:
state["response_queue"].put(", ".join(selected_choices))
self._clarify_state = None
event.app.invalidate()
return
# Only "Other" was checked → switch to freetext
self._clarify_freetext = True
event.app.invalidate()
return
# Original single-select behavior: submit the highlighted choice
if selected < len(choices):
state["response_queue"].put(choices[selected])
self._clarify_state = None
@ -14172,11 +14218,42 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._clarify_state["selected"] = min(max_idx, self._clarify_state["selected"] + 1)
event.app.invalidate()
# multi-select support: Space toggles the checkbox at the current cursor position
@kb.add('space', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext and self._clarify_state.get("multi_select")))
def clarify_toggle(event):
if self._clarify_state:
selected = self._clarify_state["selected"]
indices = self._clarify_state.get("selected_indices", set())
if selected in indices:
indices.discard(selected)
else:
indices.add(selected)
event.app.invalidate()
# Number keys for quick clarify selection (1-9, 0 for 10th item)
def _make_clarify_number_handler(idx):
def handler(event):
if self._clarify_state and not self._clarify_freetext:
choices = self._clarify_state.get("choices") or []
# multi-select support: number keys toggle checkboxes instead of submitting
if self._clarify_state.get("multi_select"):
if idx < len(choices):
indices = self._clarify_state.get("selected_indices", set())
if idx in indices:
indices.discard(idx)
else:
indices.add(idx)
event.app.invalidate()
elif idx == len(choices):
# Toggle "Other" in multi-select mode
indices = self._clarify_state.get("selected_indices", set())
if idx in indices:
indices.discard(idx)
else:
indices.add(idx)
event.app.invalidate()
return
# Original single-select: number keys submit directly
# Map index to choice (treating "Other" as the last option)
if idx < len(choices):
# Select a numbered choice
@ -15069,6 +15146,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
question = state["question"]
choices = state.get("choices") or []
selected = state.get("selected", 0)
# multi-select support
multi_select = state.get("multi_select", False)
selected_indices = state.get("selected_indices", set()) if multi_select else set()
preview_lines = _wrap_panel_text(question, 60)
for i, choice in enumerate(choices):
# Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item)
@ -15078,7 +15158,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
num_prefix = '0'
else:
num_prefix = ' '
if i == selected and not cli_ref._clarify_freetext:
if multi_select:
cb = "[x]" if i in selected_indices else "[ ]"
if i == selected and not cli_ref._clarify_freetext:
prefix = f" {cb} {num_prefix}. "
else:
prefix = f" {cb} {num_prefix}. "
elif i == selected and not cli_ref._clarify_freetext:
prefix = f" {num_prefix}. "
else:
prefix = f" {num_prefix}. "
@ -15091,11 +15177,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
other_num_prefix = '0'
else:
other_num_prefix = ' '
other_label = (
f" {other_num_prefix}. Other (type below)" if cli_ref._clarify_freetext
else f" {other_num_prefix}. Other (type your answer)" if selected == len(choices)
else f" {other_num_prefix}. Other (type your answer)"
)
other_idx_val = len(choices)
if multi_select:
cb = "[x]" if other_idx_val in selected_indices else "[ ]"
other_label = (
f" {cb} {other_num_prefix}. Other (type below)" if cli_ref._clarify_freetext
else f" {cb} {other_num_prefix}. Other (type your answer)" if selected == other_idx_val
else f" {cb} {other_num_prefix}. Other (type your answer)"
)
else:
other_label = (
f" {other_num_prefix}. Other (type below)" if cli_ref._clarify_freetext
else f" {other_num_prefix}. Other (type your answer)" if selected == len(choices)
else f" {other_num_prefix}. Other (type your answer)"
)
preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" "))
box_width = _panel_box_width("Hermes needs your input", preview_lines)
inner_text_width = max(8, box_width - 2)
@ -15111,7 +15206,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
num_prefix = '0'
else:
num_prefix = ' '
if i == selected and not cli_ref._clarify_freetext:
# multi-select support: add checkbox after cursor indicator
if multi_select:
cb = "[x]" if i in selected_indices else "[ ]"
if i == selected and not cli_ref._clarify_freetext:
prefix = f' {cb} {num_prefix}. '
else:
prefix = f' {cb} {num_prefix}. '
elif i == selected and not cli_ref._clarify_freetext:
prefix = f' {num_prefix}. '
else:
prefix = f' {num_prefix}. '
@ -15126,12 +15228,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
other_num_prefix = '0'
else:
other_num_prefix = ' '
if selected == other_idx and not cli_ref._clarify_freetext:
other_label_mand = f' {other_num_prefix}. Other (type your answer)'
elif cli_ref._clarify_freetext:
other_label_mand = f' {other_num_prefix}. Other (type below)'
# multi-select support: add checkbox to Other option
if multi_select:
cb = "[x]" if other_idx in selected_indices else "[ ]"
if selected == other_idx and not cli_ref._clarify_freetext:
other_label_mand = f' {cb} {other_num_prefix}. Other (type your answer)'
elif cli_ref._clarify_freetext:
other_label_mand = f' {cb} {other_num_prefix}. Other (type below)'
else:
other_label_mand = f' {cb} {other_num_prefix}. Other (type your answer)'
else:
other_label_mand = f' {other_num_prefix}. Other (type your answer)'
if selected == other_idx and not cli_ref._clarify_freetext:
other_label_mand = f' {other_num_prefix}. Other (type your answer)'
elif cli_ref._clarify_freetext:
other_label_mand = f' {other_num_prefix}. Other (type below)'
else:
other_label_mand = f' {other_num_prefix}. Other (type your answer)'
other_wrapped = _wrap_panel_text(other_label_mand, inner_text_width, subsequent_indent=" ")
elif cli_ref._clarify_freetext:
# Freetext-only mode: the guidance line takes the place of choices.

View file

@ -15,11 +15,14 @@ from hermes_cli.secret_prompt import masked_secret_prompt
from hermes_constants import display_hermes_home
def clarify_callback(cli, question, choices):
def clarify_callback(cli, question, choices, multi_select=False):
"""Prompt for clarifying question through the TUI.
Sets up the interactive selection UI, then blocks until the user
responds. Returns the user's choice or a timeout message.
When ``multi_select`` is True, shows checkboxes and the user can
select multiple options with Space, confirming with Enter.
"""
from cli import CLI_CONFIG
from tools.clarify_gateway import resolve_clarify_timeout
@ -29,11 +32,14 @@ def clarify_callback(cli, question, choices):
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
is_open_ended = not choices
effective_multi = multi_select and not is_open_ended
cli._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
"multi_select": effective_multi,
"selected_indices": set() if effective_multi else None,
"response_queue": response_queue,
}
cli._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout

View file

@ -469,10 +469,15 @@ def _run_agent(
logging.debug("oneshot session store cleanup failed", exc_info=True)
def _oneshot_clarify_callback(question: str, choices=None) -> str:
def _oneshot_clarify_callback(question: str, choices=None, multi_select=False) -> str:
"""Clarify is disabled in oneshot mode — tell the agent to pick a
default and proceed instead of stalling or erroring."""
if choices:
if multi_select:
return (
f"[oneshot mode: no user available. Pick the best subset from "
f"{choices} using your own judgment and continue.]"
)
return (
f"[oneshot mode: no user available. Pick the best option from "
f"{choices} using your own judgment and continue.]"

View file

@ -257,3 +257,189 @@ class TestClarifySchema:
def test_max_choices_is_four(self):
"""MAX_CHOICES constant should be 4."""
assert MAX_CHOICES == 4
def test_schema_multi_select_optional(self):
"""multi_select should not be in required list."""
assert "multi_select" not in CLARIFY_SCHEMA["parameters"]["required"]
def test_schema_multi_select_is_boolean(self):
"""multi_select should be a boolean parameter."""
ms_spec = CLARIFY_SCHEMA["parameters"]["properties"].get("multi_select")
assert ms_spec is not None
assert ms_spec["type"] == "boolean"
def test_schema_multi_select_default_false(self):
"""multi_select should default to false (not in required)."""
# The model should treat it as false when omitted
assert "multi_select" not in CLARIFY_SCHEMA["parameters"]["required"]
class TestClarifyToolMultiSelect:
"""Tests for multi_select (checkbox) support added to clarify_tool."""
def test_multi_select_false_keeps_existing_behavior(self):
"""When multi_select=False, user_response should be a single string."""
def mock_callback(question, choices):
return "blue"
result = json.loads(clarify_tool(
"What color?",
choices=["red", "blue", "green"],
multi_select=False,
callback=mock_callback,
))
assert result["user_response"] == "blue"
assert isinstance(result["user_response"], str)
def test_multi_select_true_returns_list(self):
"""When multi_select=True, user_response should be a list of strings."""
def mock_callback(question, choices):
return "red, blue"
result = json.loads(clarify_tool(
"Which colors?",
choices=["red", "blue", "green"],
multi_select=True,
callback=mock_callback,
))
assert result["user_response"] == ["red", "blue"]
assert isinstance(result["user_response"], list)
def test_multi_select_single_choice_still_list(self):
"""Even a single selection should be a list when multi_select=True."""
def mock_callback(question, choices):
return "red"
result = json.loads(clarify_tool(
"Which color?",
choices=["red", "blue"],
multi_select=True,
callback=mock_callback,
))
assert result["user_response"] == ["red"]
assert isinstance(result["user_response"], list)
def test_multi_select_with_json_array_response(self):
"""Callback can return a JSON array string for multi-select."""
def mock_callback(question, choices):
return '["red", "blue"]'
result = json.loads(clarify_tool(
"Which colors?",
choices=["red", "blue", "green"],
multi_select=True,
callback=mock_callback,
))
assert result["user_response"] == ["red", "blue"]
def test_multi_select_no_choices_falls_back_to_single_string(self):
"""When choices is None, multi_select has no effect on response type."""
def mock_callback(question, choices):
return "free form answer"
result = json.loads(clarify_tool(
"What do you think?",
multi_select=True,
callback=mock_callback,
))
# Without choices, falls back to single string response
assert result["user_response"] == "free form answer"
assert isinstance(result["user_response"], str)
def test_multi_select_default_is_false(self):
"""Default multi_select should be False (backward compatible)."""
def mock_callback(question, choices):
return "picked"
result = json.loads(clarify_tool(
"Pick one",
choices=["a", "b"],
callback=mock_callback,
))
assert result["user_response"] == "picked"
assert isinstance(result["user_response"], str)
def test_multi_select_callback_receives_flag(self):
"""Callback should receive multi_select keyword argument when supported."""
received_flag = []
def mock_callback(question, choices, **kwargs):
received_flag.append(kwargs.get("multi_select"))
return "a, b"
clarify_tool(
"Pick",
choices=["a", "b", "c"],
multi_select=True,
callback=mock_callback,
)
assert received_flag == [True]
def test_multi_select_backward_compatible_callback(self):
"""Callback that does not accept multi_select keyword should still work."""
def mock_callback(question, choices):
return "a, b"
result = json.loads(clarify_tool(
"Pick",
choices=["a", "b", "c"],
multi_select=True,
callback=mock_callback,
))
assert result["user_response"] == ["a", "b"]
def test_multi_select_empty_selection_returns_empty_list(self):
"""Empty response should produce empty list when multi_select=True."""
def mock_callback(question, choices):
return ""
result = json.loads(clarify_tool(
"Which?",
choices=["a", "b"],
multi_select=True,
callback=mock_callback,
))
assert result["user_response"] == []
def test_multi_select_whitespace_choices_stripped(self):
"""Individual selections should be stripped of whitespace."""
def mock_callback(question, choices):
return " a , b , c "
result = json.loads(clarify_tool(
"Which?",
choices=["a", "b", "c"],
multi_select=True,
callback=mock_callback,
))
assert result["user_response"] == ["a", "b", "c"]
def test_multi_select_choices_offered_preserved(self):
"""choices_offered should match what was passed in, not the response."""
def mock_callback(question, choices):
return "red, blue"
result = json.loads(clarify_tool(
"Which?",
choices=["red", "blue", "green"],
multi_select=True,
callback=mock_callback,
))
assert result["choices_offered"] == ["red", "blue", "green"]
def test_multi_select_max_choices_enforced(self):
"""MAX_CHOICES enforcement should still work with multi_select."""
choices_passed = []
def mock_callback(question, choices):
choices_passed.extend(choices or [])
return "a, b, c, d"
many_choices = ["a", "b", "c", "d", "e", "f"]
clarify_tool(
"Pick some",
choices=many_choices,
multi_select=True,
callback=mock_callback,
)
assert len(choices_passed) == MAX_CHOICES

View file

@ -6,6 +6,9 @@ Allows the agent to present structured multiple-choice questions or open-ended
prompts to the user. In CLI mode, choices are navigable with arrow keys. On
messaging platforms, choices are rendered as a numbered list.
Supports both single-select (radio) and multi-select (checkbox) modes via the
``multi_select`` parameter.
The actual user-interaction logic lives in the platform layer (cli.py for CLI,
gateway/run.py for messaging). This module defines the schema, validation, and
a thin dispatcher that delegates to a platform-provided callback.
@ -53,21 +56,64 @@ def _flatten_choice(c) -> str:
return str(c).strip()
def _invoke_callback(callback, question, choices, multi_select):
"""Invoke the platform callback, passing multi_select if supported."""
try:
return callback(question, choices, multi_select=multi_select)
except TypeError:
# Callback does not accept the multi_select keyword; fall back
return callback(question, choices)
def _parse_multi_select_response(raw_response) -> List[str]:
"""Parse a multi-select response into a list of cleaned choice strings.
Handles three forms:
- Already a list stringify + strip each element
- JSON array parse and strip
- Comma-separated split, strip, drop empties
"""
if isinstance(raw_response, list):
return [str(r).strip() for r in raw_response if str(r).strip()]
raw = str(raw_response).strip()
# Try JSON array
if raw.startswith("["):
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [str(p).strip() for p in parsed if str(p).strip()]
except json.JSONDecodeError:
pass
# Fall back to comma-separated
return [s.strip() for s in raw.split(",") if s.strip()]
def clarify_tool(
question: str,
choices: Optional[List[str]] = None,
multi_select: bool = False,
callback: Optional[Callable] = None,
) -> str:
"""
Ask the user a question, optionally with multiple-choice options.
Args:
question: The question text to present.
choices: Up to 4 predefined answer choices. When omitted the
question is purely open-ended.
callback: Platform-provided function that handles the actual UI
interaction. Signature: callback(question, choices) -> str.
Injected by the agent runner (cli.py / gateway).
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.
callback: Platform-provided function that handles the actual UI
interaction. Signature:
``callback(question, choices, multi_select=False) -> str``.
The optional ``multi_select`` keyword is passed so the
platform can render checkboxes instead of radio buttons.
Injected by the agent runner (cli.py / gateway).
Returns:
JSON string with the user's response.
@ -99,17 +145,22 @@ def clarify_tool(
)
try:
user_response = callback(question, choices)
raw_response = _invoke_callback(callback, question, choices, multi_select)
except Exception as exc:
return json.dumps(
{"error": f"Failed to get user input: {exc}"},
ensure_ascii=False,
)
if multi_select and choices is not None:
user_response = _parse_multi_select_response(raw_response)
else:
user_response = str(raw_response).strip()
return json.dumps({
"question": question,
"choices_offered": choices,
"user_response": str(user_response).strip(),
"user_response": user_response,
}, ensure_ascii=False)
@ -126,10 +177,12 @@ CLARIFY_SCHEMA = {
"name": "clarify",
"description": (
"Ask the user a question when you need clarification, feedback, or a "
"decision before proceeding. Supports two modes:\n\n"
"1. **Multiple choice** — provide up to 4 choices. The user picks one "
"decision before proceeding. Supports three modes:\n\n"
"1. **Single-select multiple choice** — provide up to 4 choices. The user picks one "
"or types their own answer via a 5th 'Other' option.\n"
"2. **Open-ended** — omit choices entirely. The user types a free-form "
"2. **Multi-select multiple choice** — set multi_select=true. The user can select "
"multiple options via checkboxes. user_response will be a list of selected choices.\n"
"3. **Open-ended** — omit choices entirely. The user types a free-form "
"response.\n\n"
"CRITICAL: when you are offering options, put each option ONLY in the "
"`choices` array — NEVER enumerate the options inside the `question` "
@ -169,6 +222,15 @@ CLARIFY_SCHEMA = {
"entirely ONLY for a genuinely open-ended free-text question."
),
},
"multi_select": {
"type": "boolean",
"description": (
"When true, the user can select MULTIPLE options (like checkboxes). "
"The user_response will be a list of selected choices. "
"When false (default), single selection (radio). "
"Has no effect when choices is omitted (open-ended question)."
),
},
},
"required": ["question"],
},
@ -185,6 +247,7 @@ registry.register(
handler=lambda args, **kw: clarify_tool(
question=args.get("question", ""),
choices=args.get("choices"),
multi_select=args.get("multi_select", False),
callback=kw.get("callback")),
check_fn=check_clarify_requirements,
emoji="",