Add ask_user tool: agent-posed multiple-choice questions (#2111)
Let the agent pause and ask the user a multiple-choice question when a task is genuinely ambiguous and the answer changes what it does next — choosing between approaches, confirming an assumption, picking a target — instead of guessing. Modeled on the existing `ui_control` marker pattern: the `ask_user` tool returns an `ask_user` payload that the agent loop emits as an SSE event and then ends the turn. The frontend renders the question with clickable option buttons, a free-text "Other" input, and an x to dismiss; the user's choice is sent as the next message and the agent resumes with it in context. - src/tool_execution.py: `ask_user` handler — pure UI marker, no I/O. Validates a non-empty question + 2..6 options, normalizes string/object options, returns the payload. - src/agent_loop.py: emit the `ask_user` event and break the round loop so the turn ends and waits for the user's selection. Stream the question as assistant text so it persists/replays (prevents a re-ask loop). - Registration: TOOL_TAGS, ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS, FUNCTION_TOOL_SCHEMAS, the system-prompt blurb. Not admin-gated (any user can be asked); the structured args serialize via the default json.dumps path. - routes/chat_routes.py: relay the `ask_user` event to the client. - static/js/chat.js + static/style.css: render the question card (options + free-text Other + dismiss x; removed once answered). Reuses CSS vars and the .modal-close button; emoji go through the monochrome-SVG pipeline. Bump chat.js cache pin. - tests/test_ask_user_tool.py: payload, multi flag, string options, option cap, validation errors, serializer round-trip, registration.
This commit is contained in:
committed by
GitHub
parent
621885ac06
commit
0a2adc9c96
@@ -1035,6 +1035,7 @@ def setup_chat_routes(
|
|||||||
"doc_stream_open", "doc_stream_delta",
|
"doc_stream_open", "doc_stream_delta",
|
||||||
"doc_update", "doc_suggestions", "ui_control",
|
"doc_update", "doc_suggestions", "ui_control",
|
||||||
"rounds_exhausted",
|
"rounds_exhausted",
|
||||||
|
"ask_user",
|
||||||
):
|
):
|
||||||
if data.get("type") == "agent_step":
|
if data.get("type") == "agent_step":
|
||||||
_agent_rounds = max(_agent_rounds, data.get("round", 1))
|
_agent_rounds = max(_agent_rounds, data.get("round", 1))
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ If the user asks for a reminder/alarm before the event, pass `reminder_minutes`
|
|||||||
"search_chats": "- ```search_chats``` — Search across all chat history. Use when user asks 'did we discuss X?' or 'find the conversation about Y'.",
|
"search_chats": "- ```search_chats``` — Search across all chat history. Use when user asks 'did we discuss X?' or 'find the conversation about Y'.",
|
||||||
"pipeline": "- ```pipeline``` — Run a multi-step AI pipeline. Args (JSON) with ordered steps, each specifying a model and prompt. Use for complex workflows.",
|
"pipeline": "- ```pipeline``` — Run a multi-step AI pipeline. Args (JSON) with ordered steps, each specifying a model and prompt. Use for complex workflows.",
|
||||||
"ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply>` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute.",
|
"ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply>` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute.",
|
||||||
|
"ask_user": "- ```ask_user``` — Ask the user a multiple-choice question when the task is genuinely ambiguous and the answer changes what you do next (pick an approach, confirm an assumption, choose a target). Args (JSON): {\"question\": \"...\", \"options\": [{\"label\": \"...\", \"description\": \"...\"?}, ...], \"multi\": false?}. 2-6 options. The user gets clickable buttons; calling this ENDS your turn and their choice comes back as your next message. Prefer sensible defaults — only ask when you truly can't proceed well without their input.",
|
||||||
"list_served_models": "- ```list_served_models``` — Show what the Cookbook (LLM-serving subsystem) is currently running. NO args. Use this for ANY 'what's running' / 'what's serving' / 'show my cookbook' / 'is anything up' query. DO NOT shell out (`ps aux`, `docker ps`, etc.) — this tool is the source of truth. Failed serve tasks include recent logs plus diagnosis/retry suggestions; use those suggestions to call `serve_model` again with an adjusted command when appropriate.",
|
"list_served_models": "- ```list_served_models``` — Show what the Cookbook (LLM-serving subsystem) is currently running. NO args. Use this for ANY 'what's running' / 'what's serving' / 'show my cookbook' / 'is anything up' query. DO NOT shell out (`ps aux`, `docker ps`, etc.) — this tool is the source of truth. Failed serve tasks include recent logs plus diagnosis/retry suggestions; use those suggestions to call `serve_model` again with an adjusted command when appropriate.",
|
||||||
"stop_served_model": "- ```stop_served_model``` — Stop a running model server. Args (JSON): {\"session_id\": \"<from list_served_models>\"}. Use for 'kill my cookbook' / 'stop the model' / 'shut down vLLM'.",
|
"stop_served_model": "- ```stop_served_model``` — Stop a running model server. Args (JSON): {\"session_id\": \"<from list_served_models>\"}. Use for 'kill my cookbook' / 'stop the model' / 'shut down vLLM'.",
|
||||||
"tail_serve_output": "- ```tail_serve_output``` — Read the actual tmux stderr/traceback of a CURRENTLY failing cookbook task. Args (JSON): {\"session_id\": \"<from list_served_models>\", \"tail\": 150?}. **Use ONLY after** you just launched something via `serve_model` AND `list_served_models` reports YOUR new task as `crashed`/`error`. DO NOT use it on old stopped/completed download tasks (they're historical noise — won't predict whether a new launch succeeds). DO NOT call it before launching a fresh attempt. When you do call it, bump `tail` to 400+ only if the visible error references 'see root cause above'.",
|
"tail_serve_output": "- ```tail_serve_output``` — Read the actual tmux stderr/traceback of a CURRENTLY failing cookbook task. Args (JSON): {\"session_id\": \"<from list_served_models>\", \"tail\": 150?}. **Use ONLY after** you just launched something via `serve_model` AND `list_served_models` reports YOUR new task as `crashed`/`error`. DO NOT use it on old stopped/completed download tasks (they're historical noise — won't predict whether a new launch succeeds). DO NOT call it before launching a fresh attempt. When you do call it, bump `tail` to 400+ only if the visible error references 'see root cause above'.",
|
||||||
@@ -1682,6 +1683,7 @@ async def stream_agent_loop(
|
|||||||
r"\b[^.\n]{0,140}",
|
r"\b[^.\n]{0,140}",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
_awaiting_user = False # set by ask_user → end the turn and wait for a choice
|
||||||
|
|
||||||
# Document streaming state (persists across rounds)
|
# Document streaming state (persists across rounds)
|
||||||
_doc_acc = "" # accumulated tool-call JSON arguments
|
_doc_acc = "" # accumulated tool-call JSON arguments
|
||||||
@@ -2263,6 +2265,28 @@ async def stream_agent_loop(
|
|||||||
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ask_user: the agent posed a multiple-choice question. Emit it so the
|
||||||
|
# frontend renders clickable options, then end the turn (below) and
|
||||||
|
# wait — the user's pick becomes the next message.
|
||||||
|
if "ask_user" in result:
|
||||||
|
# The question lives in the tool args. ChatMessage.to_dict()
|
||||||
|
# replays only role+content to the model next turn — tool_event
|
||||||
|
# metadata is dropped — so if the question is never in the saved
|
||||||
|
# assistant text, the model can't see it already asked and will
|
||||||
|
# loop and re-ask after the user answers. Stream it as assistant
|
||||||
|
# text (once) so it persists and is replayed. The card shows the
|
||||||
|
# options only, so this is the single visible copy of the question.
|
||||||
|
_auq = result["ask_user"]
|
||||||
|
_auq_q = (_auq.get("question") or "").strip()
|
||||||
|
if _auq_q and _auq_q not in full_response:
|
||||||
|
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
||||||
|
full_response += _auq_delta
|
||||||
|
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
||||||
|
yield (
|
||||||
|
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
|
||||||
|
)
|
||||||
|
_awaiting_user = True
|
||||||
|
|
||||||
# Build output for frontend tool bubble.
|
# Build output for frontend tool bubble.
|
||||||
# Document tools get a short summary — content goes to the editor panel.
|
# Document tools get a short summary — content goes to the editor panel.
|
||||||
output_text = ""
|
output_text = ""
|
||||||
@@ -2392,6 +2416,13 @@ async def stream_agent_loop(
|
|||||||
if budget_hit:
|
if budget_hit:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# ask_user posed a question — stop here and wait for the user's choice.
|
||||||
|
# Don't feed tool results back or advance a round; the user's selection
|
||||||
|
# arrives as the next message and the agent resumes from there. The
|
||||||
|
# question text is already in the streamed response, so it persists.
|
||||||
|
if _awaiting_user:
|
||||||
|
break
|
||||||
|
|
||||||
# Feed results back to LLM for next round
|
# Feed results back to LLM for next round
|
||||||
_append_tool_results(messages, round_response, native_tool_calls,
|
_append_tool_results(messages, round_response, native_tool_calls,
|
||||||
tool_results, tool_result_texts, used_native, round_num,
|
tool_results, tool_result_texts, used_native, round_num,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi
|
|||||||
"send_to_session",
|
"send_to_session",
|
||||||
"pipeline",
|
"pipeline",
|
||||||
"manage_session", "manage_memory", "list_models",
|
"manage_session", "manage_memory", "list_models",
|
||||||
"ui_control", "generate_image",
|
"ui_control", "generate_image", "ask_user",
|
||||||
"manage_tasks", "api_call", "ask_teacher", "manage_skills",
|
"manage_tasks", "api_call", "ask_teacher", "manage_skills",
|
||||||
"suggest_document",
|
"suggest_document",
|
||||||
"manage_endpoints", "manage_mcp", "manage_webhooks",
|
"manage_endpoints", "manage_mcp", "manage_webhooks",
|
||||||
|
|||||||
@@ -1184,6 +1184,53 @@ async def execute_tool_block(
|
|||||||
logger.warning("Public tool policy blocked owner=%r tool=%s", owner, tool)
|
logger.warning("Public tool policy blocked owner=%r tool=%s", owner, tool)
|
||||||
return desc, result
|
return desc, result
|
||||||
|
|
||||||
|
# ask_user: the agent poses a multiple-choice question to the user to get a
|
||||||
|
# decision/clarification. This is a pure UI-control marker — no subprocess,
|
||||||
|
# no filesystem. It returns an `ask_user` payload that the agent loop turns
|
||||||
|
# into an `ask_user` SSE event and then ENDS the turn, so the chat waits for
|
||||||
|
# the user's selection (their choice arrives as the next message).
|
||||||
|
if tool == "ask_user":
|
||||||
|
import json as _json
|
||||||
|
question, options, multi = "", [], False
|
||||||
|
raw = (content or "").strip()
|
||||||
|
try:
|
||||||
|
parsed = _json.loads(raw) if raw else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = {}
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
question = str(parsed.get("question", "")).strip()
|
||||||
|
multi = bool(parsed.get("multi") or parsed.get("multiSelect"))
|
||||||
|
for opt in (parsed.get("options") or []):
|
||||||
|
if isinstance(opt, dict):
|
||||||
|
label = str(opt.get("label", "")).strip()
|
||||||
|
descr = str(opt.get("description", "")).strip()
|
||||||
|
elif isinstance(opt, str):
|
||||||
|
label, descr = opt.strip(), ""
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if label:
|
||||||
|
options.append({"label": label, "description": descr})
|
||||||
|
else:
|
||||||
|
question = raw
|
||||||
|
if not question or len(options) < 2:
|
||||||
|
return "ask_user: invalid", {
|
||||||
|
"error": (
|
||||||
|
"ask_user needs a non-empty `question` and at least 2 `options` "
|
||||||
|
"(each an object with a `label`, optional `description`)."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
options = options[:6] # keep the choice list sane
|
||||||
|
desc = f"ask_user: {question[:80]}"
|
||||||
|
labels = ", ".join(o["label"] for o in options)
|
||||||
|
result = {
|
||||||
|
"ask_user": {"question": question, "options": options, "multi": multi},
|
||||||
|
"output": f"Asked the user: {question}\nOptions: {labels}\nAwaiting their selection.",
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
logger.info("Tool executed: %s (%d options, multi=%s)", desc, len(options), multi)
|
||||||
|
return desc, result
|
||||||
|
|
||||||
# Background execution: a `bash` block whose first line is the `#!bg`
|
# Background execution: a `bash` block whose first line is the `#!bg`
|
||||||
# marker runs DETACHED — returns a job id immediately so the chat stream
|
# marker runs DETACHED — returns a job id immediately so the chat stream
|
||||||
# isn't held open for a multi-minute install/ffmpeg/download. The always-on
|
# isn't held open for a multi-minute install/ffmpeg/download. The always-on
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ ALWAYS_AVAILABLE = frozenset({
|
|||||||
# of topic. Without this, RAG drops it and the agent falls back to
|
# of topic. Without this, RAG drops it and the agent falls back to
|
||||||
# app_api /api/memory/add which fails with 422 on first attempt.
|
# app_api /api/memory/add which fails with 422 on first attempt.
|
||||||
"manage_memory",
|
"manage_memory",
|
||||||
|
# Ask the user a multiple-choice question for a decision/clarification.
|
||||||
|
# Always reachable so the agent can pause and ask at any point.
|
||||||
|
"ask_user",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Tools that the Personal Assistant always has access to during scheduled
|
# Tools that the Personal Assistant always has access to during scheduled
|
||||||
@@ -111,6 +114,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
||||||
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||||
"search_chats": "Search through chat history across all sessions.",
|
"search_chats": "Search through chat history across all sessions.",
|
||||||
|
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||||
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
||||||
|
|||||||
@@ -447,6 +447,33 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "ask_user",
|
||||||
|
"description": "Ask the user a multiple-choice question to get a decision or clarification when the task is genuinely ambiguous and the answer changes what you do next (e.g. pick between approaches, confirm an assumption, choose a target). The user sees clickable option buttons; calling this ENDS your turn and their selection arrives as your next message. Prefer sensible defaults over asking — only ask when you truly cannot proceed well without the user's input. Do NOT use it to confirm irreversible/destructive actions that have a dedicated confirmation flow.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "2-6 mutually exclusive choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {"type": "string", "description": "Concise choice text the user clicks (1-5 words)."},
|
||||||
|
"description": {"type": "string", "description": "Optional one-line explanation of this choice."}
|
||||||
|
},
|
||||||
|
"required": ["label"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"multi": {"type": "boolean", "description": "Set true to let the user select multiple options instead of one. Default false."}
|
||||||
|
},
|
||||||
|
"required": ["question", "options"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import chatRenderer from './chatRenderer.js';
|
|||||||
import chatStream from './chatStream.js';
|
import chatStream from './chatStream.js';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
|
import { svgifyEmoji } from './markdown.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import presetsModule from './presets.js';
|
import presetsModule from './presets.js';
|
||||||
import fileHandlerModule from './fileHandler.js';
|
import fileHandlerModule from './fileHandler.js';
|
||||||
@@ -2261,6 +2262,152 @@ import createResearchSynapse from './researchSynapse.js';
|
|||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
chatStream.handleUIControl(json.data || {});
|
chatStream.handleUIControl(json.data || {});
|
||||||
|
|
||||||
|
} else if (json.type === 'ask_user') {
|
||||||
|
if (_isBg) continue;
|
||||||
|
// The agent posed a multiple-choice question; the turn has ended.
|
||||||
|
// Render clickable options at the bottom of the history. The
|
||||||
|
// user's pick is sent as the next message and the agent resumes.
|
||||||
|
_cancelThinkingTimer();
|
||||||
|
_removeThinkingSpinner();
|
||||||
|
const _aq = json.data || {};
|
||||||
|
const _opts = Array.isArray(_aq.options) ? _aq.options : [];
|
||||||
|
if (_aq.question && _opts.length) {
|
||||||
|
const chatBox = document.getElementById('chat-history');
|
||||||
|
// Drop any prior unanswered card so only the latest shows.
|
||||||
|
chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ask-user-card';
|
||||||
|
const multi = !!_aq.multi;
|
||||||
|
// Group the choices for assistive tech and label the group with
|
||||||
|
// the question (set below); make the card focusable so it can be
|
||||||
|
// moved to when it appears.
|
||||||
|
card.setAttribute('role', 'group');
|
||||||
|
card.tabIndex = -1;
|
||||||
|
// Render any emoji in agent-supplied text through the app's
|
||||||
|
// pipeline: escape, then svgify to monochrome theme-tinted
|
||||||
|
// glyphs (project rule: never colorful emoji; respects the
|
||||||
|
// "Text-only Emojis" setting like the rest of the chat).
|
||||||
|
const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
|
||||||
|
|
||||||
|
// Header row holds the close (×) to dismiss the affordances and
|
||||||
|
// just type a reply instead.
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'ask-user-head';
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'modal-close ask-user-close';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
card.remove();
|
||||||
|
const mi = uiModule.el('message');
|
||||||
|
if (mi) mi.focus();
|
||||||
|
});
|
||||||
|
head.appendChild(closeBtn);
|
||||||
|
card.appendChild(head);
|
||||||
|
|
||||||
|
// Render the question inside the card so it's self-contained:
|
||||||
|
// some models call ask_user without first narrating the question
|
||||||
|
// as assistant text, in which case the card would otherwise show
|
||||||
|
// bare options with no prompt.
|
||||||
|
if (_aq.question) {
|
||||||
|
const q = document.createElement('div');
|
||||||
|
q.className = 'ask-user-question';
|
||||||
|
q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||||
|
q.innerHTML = _emo(_aq.question);
|
||||||
|
card.appendChild(q);
|
||||||
|
// Label the choice group with the question for screen readers.
|
||||||
|
card.setAttribute('aria-labelledby', q.id);
|
||||||
|
} else {
|
||||||
|
card.setAttribute('aria-label', 'Question from the assistant');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'ask-user-options';
|
||||||
|
card.appendChild(list);
|
||||||
|
|
||||||
|
const _send = (text) => {
|
||||||
|
if (!text) return;
|
||||||
|
// Remove the card once answered — the choice is sent as a
|
||||||
|
// normal user message (and the question persists as the
|
||||||
|
// assistant text above), so the affordances are spent.
|
||||||
|
card.remove();
|
||||||
|
const mi = uiModule.el('message');
|
||||||
|
if (mi) mi.value = text;
|
||||||
|
const sb = document.querySelector('.send-btn');
|
||||||
|
if (sb) sb.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
_opts.forEach((opt, i) => {
|
||||||
|
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||||
|
if (!label) return;
|
||||||
|
const descr = (opt && opt.description) ? String(opt.description) : '';
|
||||||
|
const row = document.createElement(multi ? 'label' : 'button');
|
||||||
|
row.className = 'ask-user-option';
|
||||||
|
if (multi) {
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.value = label;
|
||||||
|
row.appendChild(cb);
|
||||||
|
}
|
||||||
|
const txt = document.createElement('span');
|
||||||
|
txt.className = 'ask-user-option-label';
|
||||||
|
txt.innerHTML = _emo(label);
|
||||||
|
row.appendChild(txt);
|
||||||
|
if (descr) {
|
||||||
|
const d = document.createElement('span');
|
||||||
|
d.className = 'ask-user-option-desc';
|
||||||
|
d.innerHTML = _emo(descr);
|
||||||
|
row.appendChild(d);
|
||||||
|
}
|
||||||
|
if (!multi) {
|
||||||
|
row.type = 'button';
|
||||||
|
row.addEventListener('click', () => _send(label));
|
||||||
|
}
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Free-text "Other" — type a custom answer + send (Enter or →).
|
||||||
|
const other = document.createElement('div');
|
||||||
|
other.className = 'ask-user-other';
|
||||||
|
const otherInput = document.createElement('input');
|
||||||
|
otherInput.type = 'text';
|
||||||
|
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||||
|
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||||
|
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||||
|
const otherSend = document.createElement('button');
|
||||||
|
otherSend.type = 'button';
|
||||||
|
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||||
|
otherSend.setAttribute('aria-label', 'Send answer');
|
||||||
|
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||||
|
const _submit = () => {
|
||||||
|
const free = otherInput.value.trim();
|
||||||
|
if (multi) {
|
||||||
|
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
|
||||||
|
if (free) picked.push(free);
|
||||||
|
if (picked.length) _send(picked.join(', '));
|
||||||
|
} else if (free) {
|
||||||
|
_send(free);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
otherSend.addEventListener('click', _submit);
|
||||||
|
otherInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
_submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
other.appendChild(otherInput);
|
||||||
|
other.appendChild(otherSend);
|
||||||
|
card.appendChild(other);
|
||||||
|
|
||||||
|
chatBox.appendChild(card);
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
// Move focus to the card so keyboard/screen-reader users land on
|
||||||
|
// the question + choices when it appears.
|
||||||
|
try { card.focus(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
} else if (json.type === 'agent_step') {
|
} else if (json.type === 'agent_step') {
|
||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
_cancelThinkingTimer();
|
_cancelThinkingTimer();
|
||||||
|
|||||||
@@ -36141,3 +36141,78 @@ body.theme-frosted .modal {
|
|||||||
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
|
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
|
||||||
100% { box-shadow: 0 0 0 2px transparent; }
|
100% { box-shadow: 0 0 0 2px transparent; }
|
||||||
}
|
}
|
||||||
|
/* ── ask_user: multiple-choice question card ─────────────────────────────
|
||||||
|
The agent posed a question and ended its turn. The user clicks an option,
|
||||||
|
types a free-text "Other" answer, or dismisses (×) to just type in the
|
||||||
|
composer. Reuses theme vars (and .modal-close for the ×) so it reads as
|
||||||
|
part of the conversation, not a modal. */
|
||||||
|
.ask-user-card {
|
||||||
|
/* Left-align like an assistant message (.msg-ai), not centered. */
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 10px auto 10px 8px;
|
||||||
|
width: 85%;
|
||||||
|
max-width: 680px;
|
||||||
|
padding: 12px 16px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--fg) 4%, var(--panel));
|
||||||
|
}
|
||||||
|
/* Focused only programmatically (tabIndex -1) to move SR/keyboard position; no
|
||||||
|
visible outline on the whole card box. */
|
||||||
|
.ask-user-card:focus { outline: none; }
|
||||||
|
.ask-user-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ask-user-close { font-size: 15px; }
|
||||||
|
.ask-user-question {
|
||||||
|
margin: -2px 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.ask-user-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ask-user-option {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
/* Match the height of the free-text input below (.styled-prompt-input). */
|
||||||
|
min-height: 39px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.ask-user-option:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent, var(--red));
|
||||||
|
background: color-mix(in srgb, var(--accent, var(--red)) 10%, var(--panel));
|
||||||
|
}
|
||||||
|
.ask-user-option:disabled { cursor: default; }
|
||||||
|
.ask-user-option-label { font-weight: 500; }
|
||||||
|
.ask-user-option-desc { opacity: 0.65; font-size: 12px; }
|
||||||
|
/* Free-text "Other" row: input + send, on one line. */
|
||||||
|
.ask-user-other {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
/* Reuses .styled-prompt-input; override its full-width + top margin so it
|
||||||
|
sits inline in the flex row next to the send button. */
|
||||||
|
.ask-user-other-input { flex: 1; min-width: 0; width: auto; margin-top: 0; }
|
||||||
|
/* Reuses .confirm-btn .confirm-btn-primary; flex-row deltas + height match to
|
||||||
|
the input beside it (.confirm-btn won't stretch on its own). */
|
||||||
|
.ask-user-other-send { flex-shrink: 0; white-space: nowrap; min-height: 39px; }
|
||||||
|
.ask-user-other-send:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|||||||
99
tests/test_ask_user_tool.py
Normal file
99
tests/test_ask_user_tool.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""`ask_user` — the agent poses a multiple-choice question to the user.
|
||||||
|
|
||||||
|
The tool is a pure UI-control marker: it does no I/O. `execute_tool_block`
|
||||||
|
returns an `ask_user` payload that the agent loop turns into an `ask_user` SSE
|
||||||
|
event and then ends the turn so the chat waits for the user's selection.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from src.agent_tools import ToolBlock, TOOL_TAGS # noqa: E402 (import first to avoid circular)
|
||||||
|
from src.tool_execution import execute_tool_block
|
||||||
|
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
||||||
|
from src.tool_security import is_public_blocked_tool
|
||||||
|
|
||||||
|
|
||||||
|
def _run(content):
|
||||||
|
return asyncio.run(execute_tool_block(ToolBlock("ask_user", content)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_question_returns_ask_user_payload():
|
||||||
|
content = json.dumps({
|
||||||
|
"question": "Which database should I use?",
|
||||||
|
"options": [
|
||||||
|
{"label": "PostgreSQL", "description": "Relational, ACID"},
|
||||||
|
{"label": "SQLite", "description": "Zero-config, file-based"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
desc, result = _run(content)
|
||||||
|
assert result.get("exit_code") == 0
|
||||||
|
assert "error" not in result
|
||||||
|
payload = result["ask_user"]
|
||||||
|
assert payload["question"] == "Which database should I use?"
|
||||||
|
assert [o["label"] for o in payload["options"]] == ["PostgreSQL", "SQLite"]
|
||||||
|
assert payload["options"][0]["description"] == "Relational, ACID"
|
||||||
|
assert payload["multi"] is False
|
||||||
|
assert "PostgreSQL" in result["output"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_flag_is_carried():
|
||||||
|
content = json.dumps({
|
||||||
|
"question": "Which features?",
|
||||||
|
"options": [{"label": "A"}, {"label": "B"}, {"label": "C"}],
|
||||||
|
"multi": True,
|
||||||
|
})
|
||||||
|
_, result = _run(content)
|
||||||
|
assert result["ask_user"]["multi"] is True
|
||||||
|
assert len(result["ask_user"]["options"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_options_are_accepted():
|
||||||
|
content = json.dumps({"question": "Pick one", "options": ["Yes", "No"]})
|
||||||
|
_, result = _run(content)
|
||||||
|
labels = [o["label"] for o in result["ask_user"]["options"]]
|
||||||
|
assert labels == ["Yes", "No"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_are_capped_at_six():
|
||||||
|
content = json.dumps({
|
||||||
|
"question": "Pick",
|
||||||
|
"options": [{"label": f"opt{i}"} for i in range(10)],
|
||||||
|
})
|
||||||
|
_, result = _run(content)
|
||||||
|
assert len(result["ask_user"]["options"]) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_fewer_than_two_options_is_rejected():
|
||||||
|
content = json.dumps({"question": "Only one?", "options": [{"label": "A"}]})
|
||||||
|
_, result = _run(content)
|
||||||
|
assert "error" in result
|
||||||
|
assert result.get("exit_code") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_question_is_rejected():
|
||||||
|
content = json.dumps({"options": [{"label": "A"}, {"label": "B"}]})
|
||||||
|
_, result = _run(content)
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_serializer_round_trips_structured_args():
|
||||||
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
|
args = {"question": "Q?", "options": [{"label": "A"}, {"label": "B"}], "multi": True}
|
||||||
|
block = function_call_to_tool_block("ask_user", json.dumps(args))
|
||||||
|
assert block is not None
|
||||||
|
assert block.tool_type == "ask_user"
|
||||||
|
assert json.loads(block.content) == args
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_everywhere():
|
||||||
|
# TOOL_TAGS gate (serializer rejects unknown tools)
|
||||||
|
assert "ask_user" in TOOL_TAGS
|
||||||
|
# Always reachable + has a retrieval description
|
||||||
|
assert "ask_user" in ALWAYS_AVAILABLE
|
||||||
|
assert "ask_user" in BUILTIN_TOOL_DESCRIPTIONS
|
||||||
|
# Function schema present
|
||||||
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||||
|
names = {s["function"]["name"] for s in FUNCTION_TOOL_SCHEMAS}
|
||||||
|
assert "ask_user" in names
|
||||||
|
# Not admin/public-gated — any user can be asked
|
||||||
|
assert is_public_blocked_tool("ask_user") is False
|
||||||
Reference in New Issue
Block a user