Ask several questions on one card

One `ask_user` call can now carry several questions, and they come back in a
single submit. Asking one at a time cost a round trip and an interruption
each, and by the third you had forgotten the first.

Each question becomes an item with its own key; several items share a call
index, because they belong to one call and one tool turn has to answer them
all. Each answer is quoted beside the question it belongs to -- with four on
a card, a bare list would leave the model matching them up by position and
sometimes getting it wrong.

Options are radios rather than submit buttons, so picking one does not send
the form while two other questions are still blank. What you type beats what
you picked: someone who writes in the box after clicking an option meant the
writing.

`_questions_in` also reads the shapes a small model actually sends -- a bare
`question` string, a list of plain strings, one object where a list belonged.
Getting that wrong costs a whole round trip and shows a card saying nothing.

Two test fixes, both mine. `test_posting_a_message_stores_both_turns` raced
the background generation it started: against a connection that refuses
instantly the reply sometimes won, writing the error and marking the row
complete before the assertions could read it. And the generation registry is
module-global, so a test that started a reply left an entry -- and a Task
belonging to a closed event loop -- for the rest of the session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 22:01:46 +02:00
parent fe25f596da
commit 4b892054a4
9 changed files with 628 additions and 135 deletions
+78 -24
View File
@@ -156,7 +156,13 @@ def request_stop(message_id: str) -> bool:
return True
def answer(chat_id: str, interaction_id: str, *, choice: str, text: str) -> bool:
def answer(
chat_id: str,
interaction_id: str,
*,
verdict: str = "",
answers: dict[str, str] | None = None,
) -> bool:
"""Resolve whichever running reply is parked on this interruption.
A linear scan of the registry: it holds one entry per reply in flight, and
@@ -167,12 +173,12 @@ def answer(chat_id: str, interaction_id: str, *, choice: str, text: str) -> bool
pending = generation.pending
if generation.chat_id != chat_id or pending is None or pending.id != interaction_id:
continue
outcome = choice if choice in _ANSWERS else interaction.ANSWER
return pending.resolve(outcome, text=text or choice)
outcome = verdict if verdict in _VERDICTS else interaction.ANSWER
return pending.resolve(outcome, answers=answers)
return False
_ANSWERS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
_VERDICTS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
def _prune() -> None:
@@ -566,19 +572,51 @@ def _ask_items(context, calls: list[dict]) -> list[interaction.Item]:
if not isinstance(args, dict):
args = {}
options = [str(o).strip() for o in (args.get("options") or []) if str(o).strip()]
items.append(
interaction.Item(
index=index,
kind=interaction.KIND_QUESTION,
tool_name=call["name"],
title=str(args.get("question") or "").strip() or "A question for you",
options=tuple(options[: interaction.MAX_OPTIONS]),
for asked in _questions_in(args):
options = [str(o).strip() for o in (asked.get("options") or []) if str(o).strip()]
items.append(
interaction.Item(
index=index,
key=f"q{len(items)}",
kind=interaction.KIND_QUESTION,
tool_name=call["name"],
title=str(asked.get("question") or "").strip() or "A question for you",
options=tuple(options[: interaction.MAX_OPTIONS]),
)
)
)
return items
def _questions_in(args: dict) -> list[dict]:
"""The questions in one `ask_user` call, however it was spelled.
The schema asks for a list of objects, and a capable model sends that. A
small one sends a bare `question` string, or a list of plain strings, or
one object where a list belonged -- all of which mean something obvious, so
they are read rather than refused. Getting this wrong costs a whole round
trip and produces a card saying "A question for you" and nothing else.
"""
raw = args.get("questions")
if raw is None:
raw = args.get("question")
if raw is None:
return []
if isinstance(raw, str | dict):
raw = [raw]
if not isinstance(raw, list):
return []
out: list[dict] = []
for entry in raw[: interaction.MAX_QUESTIONS]:
if isinstance(entry, str) and entry.strip():
# A bare string, possibly alongside a sibling `options` that was
# meant to go with it -- which only makes sense for a lone question.
out.append({"question": entry, "options": args.get("options") if len(raw) == 1 else []})
elif isinstance(entry, dict) and str(entry.get("question") or "").strip():
out.append(entry)
return out
async def _authorise(generation, context, calls: list[dict]) -> dict[int, ToolOutcome]:
"""Which of this round's calls may run, and what the others answer instead.
@@ -602,18 +640,24 @@ async def _authorise(generation, context, calls: list[dict]) -> dict[int, ToolOu
generation.stopped = True
return {}
return {item.index: _answered(item, reply) for item in items}
# Grouped back by call, because one `ask_user` call may have carried several
# questions and the endpoint expects exactly one tool turn per call.
grouped: dict[int, list[interaction.Item]] = {}
for item in items:
grouped.setdefault(item.index, []).append(item)
return {index: _answered(asked, reply) for index, asked in grouped.items()}
def _answered(item: interaction.Item, reply: interaction.Reply) -> ToolOutcome:
"""One question's answer, as the model will read it back."""
def _answered(items: list[interaction.Item], reply: interaction.Reply) -> ToolOutcome:
"""What one `ask_user` call gets back, however many questions it put."""
event = {
"name": item.tool_name,
"name": items[0].tool_name,
"kind": "ask",
"label": "Asked you",
"query": item.title,
"query": "; ".join(item.title for item in items),
"results": [],
}
if reply.outcome == interaction.EXPIRED:
return ToolOutcome(
"They did not answer. Carry on as best you can without it, or say "
@@ -621,16 +665,26 @@ def _answered(item: interaction.Item, reply: interaction.Reply) -> ToolOutcome:
{**event, "status": "error", "error": "No answer.", "text": ""},
)
answer_text = reply.text.strip()
if not answer_text:
answered = [(item, reply.answer_to(item)) for item in items]
given = [(item, text) for item, text in answered if text]
if not given:
return ToolOutcome(
"They closed the question without answering.",
{**event, "status": "error", "error": "No answer.", "text": ""},
)
return ToolOutcome(
f"They answered: {answer_text}",
{**event, "status": "ok", "text": answer_text},
)
# Each answer is quoted next to the question it belongs to. With four
# questions on one card, a bare list of answers would leave the model
# matching them up by position and sometimes getting it wrong.
lines = [f"{item.title}\n{text}" for item, text in given]
skipped = [item for item, text in answered if not text]
if skipped:
lines.append(
"They left unanswered: " + "; ".join(item.title for item in skipped)
)
body = "\n\n".join(lines)
return ToolOutcome(f"They answered:\n\n{body}", {**event, "status": "ok", "text": body})
async def _run_calls(