feat: add options support to ask_user tool

This commit is contained in:
bryan
2026-03-03 07:53:05 -08:00
parent 7b98a6613a
commit 68ac73aa55
2 changed files with 99 additions and 11 deletions
+88 -9
View File
@@ -468,8 +468,7 @@ class EventLoopNode(NodeProtocol):
if set_output_tool:
tools.append(set_output_tool)
if ctx.node_spec.client_facing and not ctx.event_triggered:
if stream_id != "queen":
tools.append(self._build_ask_user_tool())
tools.append(self._build_ask_user_tool())
# Add delegate_to_sub_agent tool if:
# - Node has sub_agents defined
@@ -1420,7 +1419,8 @@ class EventLoopNode(NodeProtocol):
human user (e.g. /chat endpoint), False for external events.
"""
await self._injection_queue.put((content, is_client_input))
self._input_ready.set()
if is_client_input:
self._input_ready.set()
def signal_shutdown(self) -> None:
"""Signal the node to exit its loop cleanly.
@@ -1756,6 +1756,56 @@ class EventLoopNode(NodeProtocol):
# --- Framework-level ask_user handling ---
user_input_requested = True
ask_user_prompt = tc.tool_input.get("question", "")
raw_options = tc.tool_input.get("options", None)
# Defensive: ensure options is a list of strings.
# Smaller models sometimes send a string instead of
# an array — try to recover gracefully.
ask_user_options: list[str] | None = None
if isinstance(raw_options, list):
ask_user_options = [str(o) for o in raw_options if o]
elif isinstance(raw_options, str) and raw_options.strip():
# Try JSON parse first (e.g. '["a","b"]')
try:
parsed = json.loads(raw_options)
if isinstance(parsed, list):
ask_user_options = [str(o) for o in parsed if o]
except (json.JSONDecodeError, TypeError):
pass
if ask_user_options is not None and len(ask_user_options) < 2:
ask_user_options = None # fall back to free-text input
# Workers MUST provide at least 2 options — no free-text
# questions allowed. Only the queen may omit options.
if ask_user_options is None and stream_id != "queen":
result = ToolResult(
tool_use_id=tc.tool_use_id,
content=(
"ERROR: options are required. Provide at least "
"2 predefined choices in the 'options' array. "
'Example: {"question": "...", "options": '
'["Yes", "No"]}'
),
is_error=True,
)
results_by_id[tc.tool_use_id] = result
user_input_requested = False
continue
# Free-form ask_user (no options): stream the question
# text as a chat message so the user can see it. When
# options are present the QuestionWidget shows the
# question, but without options nothing renders it.
if ask_user_options is None and ask_user_prompt and ctx.node_spec.client_facing:
await self._publish_text_delta(
stream_id,
node_id,
content=ask_user_prompt,
snapshot=ask_user_prompt,
ctx=ctx,
execution_id=execution_id,
iteration=iteration,
)
# Emit immediately so the frontend transitions to
# "awaiting input" without waiting for post-turn
# processing (compaction, stall check, cursor write).
@@ -1765,6 +1815,7 @@ class EventLoopNode(NodeProtocol):
node_id=node_id,
prompt=ask_user_prompt,
execution_id=execution_id,
options=ask_user_options,
)
result = ToolResult(
tool_use_id=tc.tool_use_id,
@@ -2100,20 +2151,48 @@ class EventLoopNode(NodeProtocol):
return Tool(
name="ask_user",
description=(
"Call this tool when you need to wait for the user's response. "
"Use it after greeting the user, asking a question, or requesting "
"approval. Do NOT call it when you are just providing a status "
"update or summary that doesn't require a response."
"You MUST call this tool whenever you need the user's response. "
"Always call it after greeting the user, asking a question, or "
"requesting approval. Do NOT call it for status updates or "
"summaries that don't require a response. "
"Always include 2-3 predefined options. The UI automatically "
"appends an 'Other' free-text input after your options, so NEVER "
"include catch-all options like 'Custom idea', 'Something else', "
"'Other', or 'None of the above' — the UI handles that. "
"When the question primarily needs a typed answer but you must "
"include options, make one option signal that typing is expected "
"(e.g. 'I\\'ll type my response'). This helps users discover the "
"free-text input. "
"The ONLY exception: omit options when the question demands a "
"free-form answer the user must type out (e.g. 'Describe your "
"agent idea', 'Paste the error message'). "
'Example: {"question": "What would you like to do?", "options": '
'["Build a new agent", "Modify existing agent", "Run tests"]} '
"Free-form example: "
'{"question": "Describe the agent you want to build."}'
),
parameters={
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "Optional: the question or prompt shown to the user.",
"description": "The question or prompt shown to the user.",
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": (
"2-3 specific predefined choices. Include in most cases. "
'Example: ["Option A", "Option B", "Option C"]. '
"The UI always appends an 'Other' free-text input, so "
"do NOT include catch-alls like 'Custom idea' or 'Other'. "
"Omit ONLY when the user must type a free-form answer."
),
"minItems": 2,
"maxItems": 3,
},
},
"required": [],
"required": ["question"],
},
)
+11 -2
View File
@@ -715,15 +715,24 @@ class EventBus:
node_id: str,
prompt: str = "",
execution_id: str | None = None,
options: list[str] | None = None,
) -> None:
"""Emit client input requested event (client_facing=True nodes)."""
"""Emit client input requested event (client_facing=True nodes).
Args:
options: Optional predefined choices for the user (1-3 items).
The frontend appends an "Other" free-text option automatically.
"""
data: dict[str, Any] = {"prompt": prompt}
if options:
data["options"] = options
await self.publish(
AgentEvent(
type=EventType.CLIENT_INPUT_REQUESTED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"prompt": prompt},
data=data,
)
)