From 0a2adc9c9686a2167f50589e7b045f1c3198a446 Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Fri, 5 Jun 2026 11:49:11 +0200 Subject: [PATCH] Add ask_user tool: agent-posed multiple-choice questions (#2111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- routes/chat_routes.py | 1 + src/agent_loop.py | 31 ++++++++ src/agent_tools.py | 2 +- src/tool_execution.py | 47 ++++++++++++ src/tool_index.py | 4 + src/tool_schemas.py | 27 +++++++ static/js/chat.js | 147 ++++++++++++++++++++++++++++++++++++ static/style.css | 75 ++++++++++++++++++ tests/test_ask_user_tool.py | 99 ++++++++++++++++++++++++ 9 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 tests/test_ask_user_tool.py diff --git a/routes/chat_routes.py b/routes/chat_routes.py index a18a1a6..cd5e4e6 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -1035,6 +1035,7 @@ def setup_chat_routes( "doc_stream_open", "doc_stream_delta", "doc_update", "doc_suggestions", "ui_control", "rounds_exhausted", + "ask_user", ): if data.get("type") == "agent_step": _agent_rounds = max(_agent_rounds, data.get("round", 1)) diff --git a/src/agent_loop.py b/src/agent_loop.py index 6bd9ba8..a74c95e 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -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'.", "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 on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel ` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply ` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model `, `set_theme `, `create_theme ` (optional key=val for advanced colors AND background effects: bgPattern=, bgEffectColor=#RRGGBB, bgEffectIntensity=, bgEffectSize=, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel `. 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.", "stop_served_model": "- ```stop_served_model``` — Stop a running model server. Args (JSON): {\"session_id\": \"\"}. 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\": \"\", \"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}", re.IGNORECASE, ) + _awaiting_user = False # set by ask_user → end the turn and wait for a choice # Document streaming state (persists across rounds) _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' ) + # 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. # Document tools get a short summary — content goes to the editor panel. output_text = "" @@ -2392,6 +2416,13 @@ async def stream_agent_loop( if budget_hit: 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 _append_tool_results(messages, round_response, native_tool_calls, tool_results, tool_result_texts, used_native, round_num, diff --git a/src/agent_tools.py b/src/agent_tools.py index b86bd48..c7c2a36 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -34,7 +34,7 @@ TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_fi "send_to_session", "pipeline", "manage_session", "manage_memory", "list_models", - "ui_control", "generate_image", + "ui_control", "generate_image", "ask_user", "manage_tasks", "api_call", "ask_teacher", "manage_skills", "suggest_document", "manage_endpoints", "manage_mcp", "manage_webhooks", diff --git a/src/tool_execution.py b/src/tool_execution.py index e84a414..8e44c34 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -1184,6 +1184,53 @@ async def execute_tool_block( logger.warning("Public tool policy blocked owner=%r tool=%s", owner, tool) 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` # 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 diff --git a/src/tool_index.py b/src/tool_index.py index 09e2dcf..c6eea86 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -52,6 +52,9 @@ ALWAYS_AVAILABLE = frozenset({ # 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. "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 @@ -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).", "send_to_session": "Send a message to another chat. Cross-chat communication.", "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 `. Use `open_email_reply 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_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.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 0db5ab1..7c6a639 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -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", "function": { diff --git a/static/js/chat.js b/static/js/chat.js index 3a0d1c8..d9089a7 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -12,6 +12,7 @@ import chatRenderer from './chatRenderer.js'; import chatStream from './chatStream.js'; import { addAITTSButton } from './tts-ai.js'; import markdownModule from './markdown.js'; +import { svgifyEmoji } from './markdown.js'; import spinnerModule from './spinner.js'; import presetsModule from './presets.js'; import fileHandlerModule from './fileHandler.js'; @@ -2261,6 +2262,152 @@ import createResearchSynapse from './researchSynapse.js'; if (_isBg) continue; 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') { if (_isBg) continue; _cancelThinkingTimer(); diff --git a/static/style.css b/static/style.css index c0aa39b..8243a0b 100644 --- a/static/style.css +++ b/static/style.css @@ -36141,3 +36141,78 @@ body.theme-frosted .modal { 0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); } 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; } diff --git a/tests/test_ask_user_tool.py b/tests/test_ask_user_tool.py new file mode 100644 index 0000000..edcd147 --- /dev/null +++ b/tests/test_ask_user_tool.py @@ -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