2026-02-19 20:06:14 -08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Clarify Tool Module - Interactive Clarifying Questions
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
chore: remove ~100 unused imports across 55 files (#3016)
Automated cleanup via pyflakes + autoflake with manual review.
Changes:
- Removed unused stdlib imports (os, sys, json, pathlib.Path, etc.)
- Removed unused typing imports (List, Dict, Any, Optional, Tuple, Set, etc.)
- Removed unused internal imports (hermes_cli.auth, hermes_cli.config, etc.)
- Fixed cli.py: removed 8 shadowed banner imports (imported from hermes_cli.banner
then immediately redefined locally — only build_welcome_banner is actually used)
- Added noqa comments to imports that appear unused but serve a purpose:
- Re-exports (gateway/session.py SessionResetPolicy, tools/terminal_tool.py
is_interrupted/_interrupt_event)
- SDK presence checks in try/except (daytona, fal_client, discord)
- Test mock targets (auxiliary_client.py Path, mcp_config.py get_hermes_home)
Zero behavioral changes. Full test suite passes (6162/6162, 2 pre-existing
streaming test failures unrelated to this change).
2026-03-25 15:02:03 -07:00
|
|
|
from typing import List, Optional, Callable
|
2026-02-19 20:06:14 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Maximum number of predefined choices the agent can offer.
|
|
|
|
|
# A 5th "Other (type your answer)" option is always appended by the UI.
|
|
|
|
|
MAX_CHOICES = 4
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 22:16:57 -07:00
|
|
|
def _flatten_choice(c) -> str:
|
|
|
|
|
"""Coerce a single choice into its user-facing display string.
|
|
|
|
|
|
|
|
|
|
The schema declares choices as bare strings, but LLMs sometimes emit
|
|
|
|
|
dict-shaped choices like ``[{"description": "..."}]``. A naive ``str(c)``
|
|
|
|
|
turns the whole dict into its Python repr — ``{'description': '...'}`` —
|
|
|
|
|
which then leaks onto every surface that renders the choice (CLI panel,
|
|
|
|
|
Discord buttons, Telegram numbered list) AND is returned verbatim as the
|
|
|
|
|
user's answer. Normalising here, at the one platform-agnostic entry point,
|
|
|
|
|
fixes the whole class in one place instead of per-adapter.
|
|
|
|
|
|
|
|
|
|
Dict unwrap order is the canonical LLM tool-call user-facing keys:
|
|
|
|
|
``label`` → ``description`` → ``text`` → ``title``. ``name`` and ``value``
|
|
|
|
|
are deliberately excluded — they're component-shaped fields that could
|
|
|
|
|
carry raw enum values or short identifiers, not human-readable labels. A
|
|
|
|
|
dict with none of the canonical keys is dropped (returns ""), since a
|
|
|
|
|
garbage label is worse than no choice at all.
|
|
|
|
|
"""
|
|
|
|
|
if c is None:
|
|
|
|
|
return ""
|
|
|
|
|
if isinstance(c, str):
|
|
|
|
|
return c.strip()
|
|
|
|
|
if isinstance(c, dict):
|
|
|
|
|
for key in ("label", "description", "text", "title"):
|
|
|
|
|
v = c.get(key)
|
|
|
|
|
if isinstance(v, str) and v.strip():
|
|
|
|
|
return v.strip()
|
|
|
|
|
return ""
|
|
|
|
|
if isinstance(c, (list, tuple)):
|
|
|
|
|
return " ".join(_flatten_choice(x) for x in c).strip()
|
|
|
|
|
return str(c).strip()
|
|
|
|
|
|
|
|
|
|
|
2026-02-19 20:06:14 -08:00
|
|
|
def clarify_tool(
|
|
|
|
|
question: str,
|
|
|
|
|
choices: Optional[List[str]] = None,
|
|
|
|
|
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).
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
JSON string with the user's response.
|
|
|
|
|
"""
|
|
|
|
|
if not question or not question.strip():
|
refactor: add tool_error/tool_result helpers + read_raw_config, migrate 129 callsites
Add three reusable helpers to eliminate pervasive boilerplate:
tools/registry.py — tool_error() and tool_result():
Every tool handler returns JSON strings. The pattern
json.dumps({"error": msg}, ensure_ascii=False) appeared 106 times,
and json.dumps({"success": False, "error": msg}, ...) another 23.
Now: tool_error(msg) or tool_error(msg, success=False).
tool_result() handles arbitrary result dicts:
tool_result(success=True, data=payload) or tool_result(some_dict).
hermes_cli/config.py — read_raw_config():
Lightweight YAML reader that returns the raw config dict without
load_config()'s deep-merge + migration overhead. Available for
callsites that just need a single config value.
Migration (129 callsites across 32 files):
- tools/: browser_camofox (18), file_tools (10), homeassistant (8),
web_tools (7), skill_manager (7), cronjob (11), code_execution (4),
delegate (5), send_message (4), tts (4), memory (7), session_search (3),
mcp (2), clarify (2), skills_tool (3), todo (1), vision (1),
browser (1), process_registry (2), image_gen (1)
- plugins/memory/: honcho (9), supermemory (9), hindsight (8),
holographic (7), openviking (7), mem0 (7), byterover (6), retaindb (2)
- agent/: memory_manager (2), builtin_memory_provider (1)
2026-04-07 13:36:20 -07:00
|
|
|
return tool_error("Question text is required.")
|
2026-02-19 20:06:14 -08:00
|
|
|
|
|
|
|
|
question = question.strip()
|
|
|
|
|
|
|
|
|
|
# Validate and trim choices
|
|
|
|
|
if choices is not None:
|
|
|
|
|
if not isinstance(choices, list):
|
refactor: add tool_error/tool_result helpers + read_raw_config, migrate 129 callsites
Add three reusable helpers to eliminate pervasive boilerplate:
tools/registry.py — tool_error() and tool_result():
Every tool handler returns JSON strings. The pattern
json.dumps({"error": msg}, ensure_ascii=False) appeared 106 times,
and json.dumps({"success": False, "error": msg}, ...) another 23.
Now: tool_error(msg) or tool_error(msg, success=False).
tool_result() handles arbitrary result dicts:
tool_result(success=True, data=payload) or tool_result(some_dict).
hermes_cli/config.py — read_raw_config():
Lightweight YAML reader that returns the raw config dict without
load_config()'s deep-merge + migration overhead. Available for
callsites that just need a single config value.
Migration (129 callsites across 32 files):
- tools/: browser_camofox (18), file_tools (10), homeassistant (8),
web_tools (7), skill_manager (7), cronjob (11), code_execution (4),
delegate (5), send_message (4), tts (4), memory (7), session_search (3),
mcp (2), clarify (2), skills_tool (3), todo (1), vision (1),
browser (1), process_registry (2), image_gen (1)
- plugins/memory/: honcho (9), supermemory (9), hindsight (8),
holographic (7), openviking (7), mem0 (7), byterover (6), retaindb (2)
- agent/: memory_manager (2), builtin_memory_provider (1)
2026-04-07 13:36:20 -07:00
|
|
|
return tool_error("choices must be a list of strings.")
|
2026-06-18 22:16:57 -07:00
|
|
|
# LLMs sometimes emit dict-shaped choices (e.g. [{"description": "..."}])
|
|
|
|
|
# instead of bare strings. _flatten_choice unwraps them to their
|
|
|
|
|
# user-facing text here — the single platform-agnostic entry point —
|
|
|
|
|
# so the CLI panel, Discord buttons, and Telegram list all render clean
|
|
|
|
|
# text and the resolved answer is never a raw Python dict repr.
|
|
|
|
|
choices = [s for s in (_flatten_choice(c) for c in choices) if s]
|
2026-02-19 20:06:14 -08:00
|
|
|
if len(choices) > MAX_CHOICES:
|
|
|
|
|
choices = choices[:MAX_CHOICES]
|
|
|
|
|
if not choices:
|
|
|
|
|
choices = None # empty list → open-ended
|
|
|
|
|
|
|
|
|
|
if callback is None:
|
|
|
|
|
return json.dumps(
|
|
|
|
|
{"error": "Clarify tool is not available in this execution context."},
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
user_response = callback(question, choices)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return json.dumps(
|
|
|
|
|
{"error": f"Failed to get user input: {exc}"},
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return json.dumps({
|
|
|
|
|
"question": question,
|
|
|
|
|
"choices_offered": choices,
|
|
|
|
|
"user_response": str(user_response).strip(),
|
|
|
|
|
}, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_clarify_requirements() -> bool:
|
|
|
|
|
"""Clarify tool has no external requirements -- always available."""
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# OpenAI Function-Calling Schema
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
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 "
|
|
|
|
|
"or types their own answer via a 5th 'Other' option.\n"
|
|
|
|
|
"2. **Open-ended** — omit choices entirely. The user types a free-form "
|
|
|
|
|
"response.\n\n"
|
2026-06-15 15:58:06 +05:30
|
|
|
"CRITICAL: when you are offering options, put each option ONLY in the "
|
|
|
|
|
"`choices` array — NEVER enumerate the options inside the `question` "
|
|
|
|
|
"text. The UI renders `choices` as selectable rows; options written "
|
|
|
|
|
"into the question string render as dead prose the user can't pick. "
|
|
|
|
|
"Right: question='Which deployment target?', choices=['staging', "
|
|
|
|
|
"'prod']. Wrong: question='Which target? 1) staging 2) prod', choices=[].\n\n"
|
2026-02-19 20:06:14 -08:00
|
|
|
"Use this tool when:\n"
|
|
|
|
|
"- The task is ambiguous and you need the user to choose an approach\n"
|
|
|
|
|
"- You want post-task feedback ('How did that work out?')\n"
|
|
|
|
|
"- You want to offer to save a skill or update memory\n"
|
|
|
|
|
"- A decision has meaningful trade-offs the user should weigh in on\n\n"
|
|
|
|
|
"Do NOT use this tool for simple yes/no confirmation of dangerous "
|
|
|
|
|
"commands (the terminal tool handles that). Prefer making a reasonable "
|
|
|
|
|
"default choice yourself when the decision is low-stakes."
|
|
|
|
|
),
|
|
|
|
|
"parameters": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"question": {
|
|
|
|
|
"type": "string",
|
2026-06-15 15:58:06 +05:30
|
|
|
"description": (
|
|
|
|
|
"The question itself, and ONLY the question (e.g. 'Which "
|
|
|
|
|
"deployment target?'). Do NOT embed the answer options here "
|
|
|
|
|
"— pass them as separate elements in `choices`."
|
|
|
|
|
),
|
2026-02-19 20:06:14 -08:00
|
|
|
},
|
|
|
|
|
"choices": {
|
|
|
|
|
"type": "array",
|
|
|
|
|
"items": {"type": "string"},
|
|
|
|
|
"maxItems": MAX_CHOICES,
|
|
|
|
|
"description": (
|
2026-06-15 15:58:06 +05:30
|
|
|
"REQUIRED whenever you are presenting selectable options: "
|
|
|
|
|
"each distinct option is its own array element (up to 4). "
|
|
|
|
|
"The UI renders these as pickable rows and auto-appends an "
|
|
|
|
|
"'Other (type your answer)' option. Omit this parameter "
|
|
|
|
|
"entirely ONLY for a genuinely open-ended free-text question."
|
2026-02-19 20:06:14 -08:00
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
"required": ["question"],
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Registry ---
|
refactor: add tool_error/tool_result helpers + read_raw_config, migrate 129 callsites
Add three reusable helpers to eliminate pervasive boilerplate:
tools/registry.py — tool_error() and tool_result():
Every tool handler returns JSON strings. The pattern
json.dumps({"error": msg}, ensure_ascii=False) appeared 106 times,
and json.dumps({"success": False, "error": msg}, ...) another 23.
Now: tool_error(msg) or tool_error(msg, success=False).
tool_result() handles arbitrary result dicts:
tool_result(success=True, data=payload) or tool_result(some_dict).
hermes_cli/config.py — read_raw_config():
Lightweight YAML reader that returns the raw config dict without
load_config()'s deep-merge + migration overhead. Available for
callsites that just need a single config value.
Migration (129 callsites across 32 files):
- tools/: browser_camofox (18), file_tools (10), homeassistant (8),
web_tools (7), skill_manager (7), cronjob (11), code_execution (4),
delegate (5), send_message (4), tts (4), memory (7), session_search (3),
mcp (2), clarify (2), skills_tool (3), todo (1), vision (1),
browser (1), process_registry (2), image_gen (1)
- plugins/memory/: honcho (9), supermemory (9), hindsight (8),
holographic (7), openviking (7), mem0 (7), byterover (6), retaindb (2)
- agent/: memory_manager (2), builtin_memory_provider (1)
2026-04-07 13:36:20 -07:00
|
|
|
from tools.registry import registry, tool_error
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
registry.register(
|
|
|
|
|
name="clarify",
|
|
|
|
|
toolset="clarify",
|
|
|
|
|
schema=CLARIFY_SCHEMA,
|
|
|
|
|
handler=lambda args, **kw: clarify_tool(
|
|
|
|
|
question=args.get("question", ""),
|
|
|
|
|
choices=args.get("choices"),
|
|
|
|
|
callback=kw.get("callback")),
|
|
|
|
|
check_fn=check_clarify_requirements,
|
2026-03-15 20:21:21 -07:00
|
|
|
emoji="❓",
|
2026-02-21 20:22:33 -08:00
|
|
|
)
|