"""The `ask_user` tool — the agent asks the user a question and waits for the answer. The general human-in-the-loop Q&A primitive, modelled on Claude Code's own AskUserQuestion: a question, optional quick-reply `options`, and (by default) an always-available free-text escape — plus `multi` for choose-several. Like `request_directory`, it's intercepted by the TurnEngine: the question becomes an Inbox item (answerable inline in the live session, or from the Inbox when the session runs unattended), the agent suspends until it's resolved, and the answer comes back as the tool result. The callable here is only a schema carrier + a safe fallback. OPE-51 upgrades: options may be rich objects ({label, description, recommended, preview}) instead of plain strings, and `questions` groups up to 4 questions into ONE call (rendered as a stepper — one agent round-trip instead of several). Plain-string options and the singular `question` form stay valid: old sessions and simple asks render exactly as before. """ from __future__ import annotations import json from aisuite.agents import ToolMetadata, tool # How many questions one grouped call may carry (stepper chips get unreadable past this). MAX_GROUPED_QUESTIONS = 4 # An option is a plain string OR a rich object. `label` is what the user picks (and what comes # back as the answer); `description` renders under it; `recommended` adds the green tag (put the # recommended option first); `preview` is monospace text shown in the side pane (code, config, # ASCII mockups, SQL — any text; when ≥1 option has one the card switches to two-pane layout). _OPTION_SCHEMA = { "anyOf": [ {"type": "string"}, { "type": "object", "properties": { "label": {"type": "string"}, "description": {"type": "string"}, "recommended": {"type": "boolean"}, "preview": {"type": "string"}, }, "required": ["label"], }, ] } # Explicit schema (same pattern as todo.py): the string-or-object option union and the nested # `questions` array can't be auto-generated from the signature reliably. _ASK_SCHEMA = { "type": "function", "function": { "name": "ask_user", "description": ( "Ask the user one or more questions and wait for their answer. Use for decisions or " "information only the user can provide. Do not use it to ask permission for a " "specific action you are about to take — propose the action instead; the approval " "flow shows the user exactly what would run and does the asking. Group related " f"questions (up to {MAX_GROUPED_QUESTIONS}) into one call via `questions` instead " "of asking serially." ), "parameters": { "type": "object", "properties": { "question": { "type": "string", "description": "The full question, in plain language (single-question form).", }, "options": { "type": "array", "items": _OPTION_SCHEMA, "description": ( "Optional quick-reply choices: plain strings, or objects with `label` " "(required — this is the answer value), `description` (why/when to pick " "it), `recommended` (green tag; list that option first), and `preview` " "(monospace text — code, config, a mockup — shown in a side pane)." ), }, "allow_text": { "type": "boolean", "description": ( "Keep a free-text answer available even when options exist (default true; " "the \"Other / type your own\" escape). Set false only when the options " "are exhaustive." ), }, "multi": { "type": "boolean", "description": "Allow the user to pick more than one option.", }, "header": { "type": "string", "description": "Short (≤ ~12 char) chip label for the card, e.g. \"Region\".", }, "questions": { "type": "array", "maxItems": MAX_GROUPED_QUESTIONS, "items": { "type": "object", "properties": { "question": {"type": "string"}, "header": { "type": "string", "description": ( "Short (≤ ~12 char) label — names this step in the stepper " "chips and keys its answer in the result." ), }, "options": {"type": "array", "items": _OPTION_SCHEMA}, "allow_text": {"type": "boolean"}, "multi": {"type": "boolean"}, }, "required": ["question"], }, "description": ( f"Grouped form: up to {MAX_GROUPED_QUESTIONS} questions asked in ONE " "round-trip, rendered as a stepper. When set, the singular " "question/options fields are ignored." ), }, }, "required": [], }, }, } def ask_user_tool() -> object: def ask_user( question: str = "", options: list | None = None, allow_text: bool = True, multi: bool = False, header: str = "", questions: list | None = None, ) -> dict: """Ask the user a question and wait for their answer — use when you genuinely need a human decision or information you can't infer (a preference, a missing fact, a choice between real alternatives). Prefer this over guessing or stalling. Never use it to ask permission for a specific action you are about to take ("shall I open a PR?") — propose the action instead: the approval flow shows the user the exact command/arguments and does the asking, which is stronger consent than a chat yes. Single form returns `{"answer": "..."}` — the chosen option label(s) or the typed text. Grouped form (`questions`) returns `{"answers": {"
": "..."}}` — one entry per question. Don't ask what you can reasonably decide yourself; reserve this for choices that are actually the user's to make. """ # Real handling lives in the engine (it needs the out-of-band Inbox round-trip). This body # only runs if no question_asker is wired (e.g. a headless surface). return { "answer": "", "error": "asking the user isn't available in this surface", } wrapped = tool( ask_user, metadata=ToolMetadata( category="interaction", risk_level="low", capabilities=["ask_user"], description=( "Ask the user a question (free-text or multiple-choice) and wait for their answer. " "Use for decisions or information only the user can provide — never to ask " "permission for a specific action; propose the action and let the approval flow ask." ), ), ) wrapped.__coworker_schema__ = _ASK_SCHEMA return wrapped def normalize_option(opt) -> dict: """One option in canonical dict form: {label, description, recommended, preview}. Plain strings become {label: str, ...empty}. The label doubles as the answer value everywhere (buttons, pills, resolutions), so it is always a non-empty-able str.""" if isinstance(opt, dict): return { "label": str(opt.get("label", "")), "description": str(opt.get("description", "")), "recommended": bool(opt.get("recommended", False)), "preview": str(opt.get("preview", "")), } return {"label": str(opt), "description": "", "recommended": False, "preview": ""} def option_label(opt) -> str: """The answer value / button text for a str-or-dict option.""" return str(opt.get("label", "")) if isinstance(opt, dict) else str(opt) def normalize_questions(raw) -> list[dict]: """The grouped `questions` arg in canonical form (capped, blanks dropped). Each entry: {question, header, options: [canonical option], allow_text, multi}.""" out: list[dict] = [] for entry in list(raw or [])[:MAX_GROUPED_QUESTIONS]: if not isinstance(entry, dict): continue q = str(entry.get("question", "")).strip() if not q: continue out.append( { "question": q, "header": str(entry.get("header", "")), "options": [normalize_option(o) for o in entry.get("options") or []], "allow_text": bool(entry.get("allow_text", True)), "multi": bool(entry.get("multi", False)), } ) return out def question_item_fields(args: dict) -> dict | None: """`InboxStore.add_question` kwargs from raw ask_user args, or None when nothing was asked. A grouped call surfaces its FIRST question as title/options too, so legacy surfaces (channel mirrors, old persisted-item readers) degrade to a sensible single question.""" grouped = normalize_questions(args.get("questions")) if grouped: first = grouped[0] return { "title": first["question"], "options": first["options"], "allow_text": first["allow_text"], "multi": first["multi"], "header": first["header"], "questions": grouped, } question = str(args.get("question", "")).strip() if not question: return None return { "title": question, # Strings pass through untouched (simple asks keep rendering as today's pills); # rich objects are canonicalized so downstream never meets a half-filled dict. "options": [ o if isinstance(o, str) else normalize_option(o) for o in args.get("options") or [] ], "allow_text": bool(args.get("allow_text", True)), "multi": bool(args.get("multi", False)), "header": str(args.get("header", "")), "questions": [], } def answer_result(item_questions: list, resolution: str | None) -> dict: """Shape the ask_user tool result from an Inbox item's resolution string. Grouped items resolve with a JSON object string keyed by header-or-question → `{"answers": {...}}`; everything else returns the plain `{"answer": str}` shape.""" if item_questions: try: parsed = json.loads(resolution or "") except (ValueError, TypeError): parsed = None if isinstance(parsed, dict): return {"answers": {str(k): str(v) for k, v in parsed.items()}} if resolution: # Answered from a text-only surface (e.g. a mirrored channel): attribute the lone # answer to the first question rather than losing it. first = item_questions[0] if isinstance(item_questions[0], dict) else {} key = str(first.get("header") or first.get("question") or "answer") return {"answers": {key: str(resolution)}} return {"answer": ""} return {"answer": resolution or ""}