feat: introduce clarifying questions tool for interactive user engagement

- Added a new `clarify_tool` to enable the agent to ask structured multiple-choice or open-ended questions to users.
- Implemented callback functionality for user interaction, allowing the platform to handle UI presentation.
- Updated the CLI and agent to support clarify questions, including timeout handling and response management.
- Enhanced toolset definitions and requirements to include the clarify tool, ensuring availability across platforms.
This commit is contained in:
teknium1 2026-02-19 20:06:14 -08:00
parent 997f793af1
commit 9350e26e68
6 changed files with 386 additions and 2 deletions

194
cli.py
View file

@ -32,7 +32,8 @@ from prompt_toolkit.history import FileHistory
from prompt_toolkit.styles import Style as PTStyle
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.application import Application
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer
from prompt_toolkit.filters import Condition
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.widgets import TextArea
@ -716,6 +717,7 @@ class HermesCLI:
# Agent will be initialized on first use
self.agent: Optional[AIAgent] = None
self._app = None # prompt_toolkit Application (set in run())
# Conversation state
self.conversation_history: List[Dict[str, Any]] = []
@ -761,6 +763,7 @@ class HermesCLI:
session_id=self.session_id, # Pass CLI's session ID to agent
platform="cli", # CLI interface — agent uses terminal-friendly formatting
session_db=self._session_db,
clarify_callback=self._clarify_callback,
)
return True
except Exception as e:
@ -1443,6 +1446,51 @@ class HermesCLI:
return True
# How long to wait for the user to answer a clarify question before
# the agent auto-proceeds with its own judgment (seconds).
CLARIFY_TIMEOUT = 120
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
Sets up the interactive selection UI (or freetext prompt for open-ended
questions), then blocks until the user responds via the prompt_toolkit
key bindings. If no response arrives within CLARIFY_TIMEOUT seconds the
question is dismissed and the agent is told to decide on its own.
"""
response_queue = queue.Queue()
is_open_ended = not choices or len(choices) == 0
self._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
"response_queue": response_queue,
}
# Open-ended questions skip straight to freetext input
self._clarify_freetext = is_open_ended
# Trigger prompt_toolkit repaint from this (non-main) thread
if hasattr(self, '_app') and self._app:
self._app.invalidate()
# Block until the user answers, or time out so automated /
# unattended sessions aren't stuck forever.
try:
return response_queue.get(timeout=self.CLARIFY_TIMEOUT)
except queue.Empty:
# Timed out — tear down the UI and let the agent decide
self._clarify_state = None
self._clarify_freetext = False
if hasattr(self, '_app') and self._app:
self._app.invalidate()
_cprint(f"\n{_DIM}(clarify timed out after {self.CLARIFY_TIMEOUT}s — agent will decide){_RST}")
return (
"The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed."
)
def chat(self, message: str) -> Optional[str]:
"""
Send a message to the agent and get a response.
@ -1487,12 +1535,20 @@ class HermesCLI:
# Monitor the dedicated interrupt queue while the agent runs.
# _interrupt_queue is separate from _pending_input, so process_loop
# and chat() never compete for the same queue.
# When a clarify question is active, user input is handled entirely
# by the Enter key binding (routed to the clarify response queue),
# so we skip interrupt processing to avoid stealing that input.
interrupt_msg = None
while agent_thread.is_alive():
if hasattr(self, '_interrupt_queue'):
try:
interrupt_msg = self._interrupt_queue.get(timeout=0.1)
if interrupt_msg:
# If clarify is active, the Enter handler routes
# input directly; this queue shouldn't have anything.
# But if it does (race condition), don't interrupt.
if self._clarify_state or self._clarify_freetext:
continue
print(f"\n⚡ New message detected, interrupting...")
self.agent.interrupt(interrupt_msg)
break
@ -1566,6 +1622,12 @@ class HermesCLI:
self._interrupt_queue = queue.Queue() # For messages typed while agent is running
self._should_exit = False
self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit
# Clarify tool state: interactive question/answer with the user.
# When the agent calls the clarify tool, _clarify_state is set and
# the prompt_toolkit UI switches to a selection mode.
self._clarify_state = None # dict with question, choices, selected, response_queue
self._clarify_freetext = False # True when user chose "Other" and is typing
# Key bindings for the input area
kb = KeyBindings()
@ -1575,11 +1637,40 @@ class HermesCLI:
"""Handle Enter key - submit input.
Routes to the correct queue based on agent state:
- Clarify freetext mode: answer goes to the clarify response queue
- Clarify choice mode: selected choice goes to the clarify response queue
- Agent running: goes to _interrupt_queue (chat() monitors this)
- Agent idle: goes to _pending_input (process_loop monitors this)
Commands (starting with /) always go to _pending_input so they're
handled as commands, not sent as interrupt text to the agent.
"""
# --- Clarify freetext mode: user typed their own answer ---
if self._clarify_freetext and self._clarify_state:
text = event.app.current_buffer.text.strip()
if text:
self._clarify_state["response_queue"].put(text)
self._clarify_state = None
self._clarify_freetext = False
event.app.current_buffer.reset()
event.app.invalidate()
return
# --- Clarify choice mode: confirm the highlighted selection ---
if self._clarify_state and not self._clarify_freetext:
state = self._clarify_state
selected = state["selected"]
choices = state.get("choices") or []
if selected < len(choices):
state["response_queue"].put(choices[selected])
self._clarify_state = None
event.app.invalidate()
else:
# "Other" selected → switch to freetext
self._clarify_freetext = True
event.app.invalidate()
return
# --- Normal input routing ---
text = event.app.current_buffer.text.strip()
if text:
if self._agent_running and not text.startswith("/"):
@ -1597,6 +1688,24 @@ class HermesCLI:
def handle_ctrl_enter(event):
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
event.current_buffer.insert_text('\n')
# --- Clarify tool: arrow-key navigation for multiple-choice questions ---
@kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))
def clarify_up(event):
"""Move selection up in clarify choices."""
if self._clarify_state:
self._clarify_state["selected"] = max(0, self._clarify_state["selected"] - 1)
event.app.invalidate()
@kb.add('down', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))
def clarify_down(event):
"""Move selection down in clarify choices."""
if self._clarify_state:
choices = self._clarify_state.get("choices") or []
max_idx = len(choices) # last index is the "Other" option
self._clarify_state["selected"] = min(max_idx, self._clarify_state["selected"] + 1)
event.app.invalidate()
@kb.add('c-c')
def handle_ctrl_c(event):
@ -1631,10 +1740,15 @@ class HermesCLI:
self._should_exit = True
event.app.exit()
# Dynamic prompt: shows Hermes symbol when agent is working
# Dynamic prompt: shows Hermes symbol when agent is working,
# or answer prompt when clarify freetext mode is active.
cli_ref = self
def get_prompt():
if cli_ref._clarify_freetext:
return [('class:clarify-selected', ' ')]
if cli_ref._clarify_state:
return [('class:prompt-working', '? ')]
if cli_ref._agent_running:
return [('class:prompt-working', ' ')]
return [('class:prompt', ' ')]
@ -1691,18 +1805,83 @@ class HermesCLI:
def get_hint_text():
if not cli_ref._agent_running:
return []
# When clarify is active, show a different hint
if cli_ref._clarify_state:
if cli_ref._clarify_freetext:
return [('class:hint', ' type your answer and press Enter')]
return [('class:hint', ' ↑/↓ to select, Enter to confirm')]
buf = input_area.buffer
if buf.text:
return []
return [('class:hint', ' type here to interrupt')]
def get_hint_height():
if cli_ref._clarify_state:
return 1
return 1 if cli_ref._agent_running else 0
spacer = Window(
content=FormattedTextControl(get_hint_text),
height=get_hint_height,
)
# --- Clarify tool: dynamic display widget for questions + choices ---
def _get_clarify_display():
"""Build styled text for the clarify question/choices panel."""
state = cli_ref._clarify_state
if not state:
return []
question = state["question"]
choices = state.get("choices") or []
selected = state.get("selected", 0)
lines = []
# Box top border
lines.append(('class:clarify-border', '╭─ '))
lines.append(('class:clarify-title', 'Hermes needs your input'))
lines.append(('class:clarify-border', ' ─────────────────────────────╮\n'))
lines.append(('class:clarify-border', '\n'))
# Question text
lines.append(('class:clarify-border', ''))
lines.append(('class:clarify-question', question))
lines.append(('', '\n'))
lines.append(('class:clarify-border', '\n'))
if choices:
# Multiple-choice mode: show selectable options
for i, choice in enumerate(choices):
lines.append(('class:clarify-border', ''))
if i == selected and not cli_ref._clarify_freetext:
lines.append(('class:clarify-selected', f' {choice}'))
else:
lines.append(('class:clarify-choice', f' {choice}'))
lines.append(('', '\n'))
# "Other" option (5th line, only shown when choices exist)
other_idx = len(choices)
lines.append(('class:clarify-border', ''))
if selected == other_idx and not cli_ref._clarify_freetext:
lines.append(('class:clarify-selected', ' Other (type your answer)'))
elif cli_ref._clarify_freetext:
lines.append(('class:clarify-active-other', ' Other (type below)'))
else:
lines.append(('class:clarify-choice', ' Other (type your answer)'))
lines.append(('', '\n'))
lines.append(('class:clarify-border', '\n'))
lines.append(('class:clarify-border', '╰──────────────────────────────────────────────────╯\n'))
return lines
clarify_widget = ConditionalContainer(
Window(
FormattedTextControl(_get_clarify_display),
wrap_lines=True,
),
filter=Condition(lambda: cli_ref._clarify_state is not None),
)
# Horizontal rules above and below the input (bronze, 1 line each).
# The bottom rule moves down as the TextArea grows with newlines.
@ -1720,9 +1899,12 @@ class HermesCLI:
# after agent output has filled the terminal via patch_stdout. Float-based
# menus lose their rendering space in non-full-screen mode once scrollback
# pushes the app area to the very bottom of the terminal.
# The clarify_widget appears above the input area when the agent
# asks a multiple-choice or open-ended question.
layout = Layout(
HSplit([
Window(height=0),
clarify_widget,
spacer,
input_rule_top,
input_area,
@ -1744,6 +1926,13 @@ class HermesCLI:
'completion-menu.completion.current': 'bg:#333355 #FFD700',
'completion-menu.meta.completion': 'bg:#1a1a2e #888888',
'completion-menu.meta.completion.current': 'bg:#333355 #FFBF00',
# Clarify question panel
'clarify-border': '#CD7F32',
'clarify-title': '#FFD700 bold',
'clarify-question': '#FFF8DC bold',
'clarify-choice': '#AAAAAA',
'clarify-selected': '#FFD700 bold',
'clarify-active-other': '#FFD700 italic',
})
# Create the application
@ -1754,6 +1943,7 @@ class HermesCLI:
full_screen=False,
mouse_support=False,
)
self._app = app # Store reference for clarify_callback
# Background thread to process inputs and run agent
def process_loop():