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:
Kenny Van de Maele
2026-06-05 11:49:11 +02:00
committed by GitHub
parent 621885ac06
commit 0a2adc9c96
9 changed files with 432 additions and 1 deletions

View File

@@ -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))

View File

@@ -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 <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.",
"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'.",
@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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 <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_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.",

View File

@@ -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": {

View File

@@ -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();

View File

@@ -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; }

View 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