mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
bd1db5460a
commit
3e2f91f6b3
5 changed files with 398 additions and 26 deletions
138
cli.py
138
cli.py
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.]"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="❓",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue