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:
co-authored by
Claude Opus 5
parent
fe25f596da
commit
4b892054a4
@@ -49,18 +49,29 @@ PERMITTED = (ALLOW, ALLOW_ALWAYS)
|
||||
# A card offering more than this many buttons is a card nobody reads.
|
||||
MAX_OPTIONS = 6
|
||||
|
||||
# And more than this many questions at once is a form, not a conversation. A
|
||||
# model that wants twenty answers should ask for four and then ask again with
|
||||
# what it learned.
|
||||
MAX_QUESTIONS = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Item:
|
||||
"""One thing being asked about.
|
||||
"""One thing being asked about: a single question, or one command.
|
||||
|
||||
`index` is the position of the call in its round, so a decision can be
|
||||
matched back to the call it was about -- the tool turns have to line up with
|
||||
the assistant turn's `tool_calls` or an endpoint pairs the wrong result with
|
||||
the right id.
|
||||
`index` is the position of the *call* in its round, so an answer can be
|
||||
matched back to the call it belongs to -- the tool turns have to line up
|
||||
with the assistant turn's `tool_calls`, or an endpoint pairs the wrong
|
||||
result with the right id. Several items can share an index, because one
|
||||
`ask_user` call may carry several questions.
|
||||
|
||||
`key` identifies this item within the card, and is what the form field is
|
||||
named after. Stable and opaque: a question's own text would make a terrible
|
||||
field name, and its position alone would collide across calls.
|
||||
"""
|
||||
|
||||
index: int
|
||||
key: str
|
||||
kind: str
|
||||
tool_name: str
|
||||
title: str
|
||||
@@ -83,18 +94,7 @@ class Interruption:
|
||||
def kind(self) -> str:
|
||||
return KIND_QUESTION if any(i.kind == KIND_QUESTION for i in self.items) else KIND_APPROVAL
|
||||
|
||||
@property
|
||||
def options(self) -> tuple[str, ...]:
|
||||
for item in self.items:
|
||||
if item.options:
|
||||
return item.options
|
||||
return ()
|
||||
|
||||
@property
|
||||
def allow_free_text(self) -> bool:
|
||||
return any(item.allow_free_text for item in self.items)
|
||||
|
||||
def resolve(self, outcome: str, *, text: str = "") -> bool:
|
||||
def resolve(self, outcome: str, *, answers: dict[str, str] | None = None) -> bool:
|
||||
"""Complete this pause. Idempotent -- a second answer is ignored.
|
||||
|
||||
Returns whether this call was the one that answered it, which is what
|
||||
@@ -103,14 +103,21 @@ class Interruption:
|
||||
"""
|
||||
if self._future is None or self._future.done():
|
||||
return False
|
||||
self._future.set_result(Reply(outcome=outcome, text=text))
|
||||
self._future.set_result(Reply(outcome=outcome, answers=dict(answers or {})))
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reply:
|
||||
"""How a card was answered.
|
||||
|
||||
`answers` is keyed by `Item.key`, so a card carrying four questions comes
|
||||
back as four answers in one go. An approval carries none: the verdict is
|
||||
the whole of it.
|
||||
"""
|
||||
|
||||
outcome: str
|
||||
text: str = ""
|
||||
answers: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def permitted(self) -> bool:
|
||||
@@ -121,6 +128,9 @@ class Reply:
|
||||
"""Whether this outcome means the whole reply should stop."""
|
||||
return self.outcome == CANCELLED
|
||||
|
||||
def answer_to(self, item: Item) -> str:
|
||||
return (self.answers.get(item.key) or "").strip()
|
||||
|
||||
|
||||
def build(
|
||||
interaction_id: str, items: list[Item] | tuple[Item, ...], *, timeout: float
|
||||
@@ -163,7 +173,7 @@ def summarise(items: tuple[Item, ...]) -> str:
|
||||
if not items:
|
||||
return ""
|
||||
if items[0].kind == KIND_QUESTION:
|
||||
return "Waiting for your answer…"
|
||||
return "Waiting for your answer…" if len(items) == 1 else "Waiting for your answers…"
|
||||
if len(items) == 1:
|
||||
return f"Waiting for you to allow {items[0].tool_name}…"
|
||||
return f"Waiting for you to allow {len(items)} actions…"
|
||||
@@ -179,6 +189,7 @@ __all__ = [
|
||||
"KIND_APPROVAL",
|
||||
"KIND_QUESTION",
|
||||
"MAX_OPTIONS",
|
||||
"MAX_QUESTIONS",
|
||||
"PERMITTED",
|
||||
"Interruption",
|
||||
"Item",
|
||||
|
||||
Reference in New Issue
Block a user