From c0b72df6affc86620491e47c3623dd86f0e9a912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 4 Aug 2026 19:34:47 +0200 Subject: [PATCH] A question that offers real choices, and says how many you may take Three things about `ask_user`, all of them about the card being answerable rather than about the tool being callable. Options are required now, and they are objects: a label, and a line of description where the label alone does not say what choosing it would mean. "Rewrite it" and "Patch it" are two words that do not tell you which one loses your uncommitted work. They stack one per line, because a row of chips has nowhere to put the second line and no room to read the first. The model says whether they are exclusive. Only it knows whether its options are alternatives or a set, and the card has to show which -- a radio group offered where checkboxes were meant loses every answer but one. Exclusive is the default, being the cheaper mistake. A `multiple` question posts the same field name once per ticked box, so the endpoint gathers choices into a list; the `setdefault` it did before kept the first and dropped the rest, which is an answer that says something the reader did not. And "Something else" is added here, on every question, with the box behind it revealed by `:has()` and no JavaScript at all. The model is told never to write an "other" option of its own, because its version would be a choice with no box behind it -- a word submitted that means nothing. It carries a sentinel rather than an answer, and the endpoint swaps in what was typed beside it, or drops it when the box was left empty rather than telling the model the answer is "__other__". Typing no longer beats picking. That rule belonged to a box that was always visible next to the options; this one only exists once its own option is chosen, so picking is the answer and the box is one of the things you can pick. Co-Authored-By: Claude Opus 5 (1M context) --- src/lembas/api/chats.py | 37 ++- src/lembas/services/generation.py | 41 ++- src/lembas/services/interaction.py | 39 ++- src/lembas/services/tools.py | 54 +++- src/lembas/web/static/css/chat.css | 61 ++++- .../web/templates/chat/_interaction.html | 55 +++- tests/test_agent_interaction.py | 243 +++++++++++++++++- 7 files changed, 491 insertions(+), 39 deletions(-) diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index ed4e691..283ad94 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -867,7 +867,10 @@ def _ask_html(chat_id: str, pending) -> str: if pending is None: return "" return templates.get_template("chat/_interaction.html").render( - {"ask": pending, "chat_id": chat_id} + # The sentinel the "Something else" row submits, passed in rather than + # written into the template, so the value the card sends and the value + # this module looks for cannot drift apart. + {"ask": pending, "chat_id": chat_id, "other_value": interaction.OTHER} ) @@ -1412,16 +1415,40 @@ async def answer_interaction( form = await request.form() verdict = str(form.get("verdict") or "").strip() - answers: dict[str, str] = {} + # Gathered in two passes because one field can now arrive several times: a + # question the model marked `multiple` is checkboxes, and every ticked one + # posts under the same name. A `setdefault` would keep the first and lose + # the rest, which is an answer that says something the reader did not. + chosen: dict[str, list[str]] = {} + typed: dict[str, str] = {} for field, value in form.multi_items(): kind, _, key = str(field).partition(".") if not key or kind not in ("choice", "text"): continue written = str(value).strip() - if kind == "text" and written: + if kind == "text": + typed[key] = written + elif written: + chosen.setdefault(key, []).append(written) + + answers: dict[str, str] = {} + for key, picks in chosen.items(): + # The "Something else" row carries a sentinel, not an answer: what it + # means is whatever was typed beside it. Dropped entirely when the box + # was left empty, so ticking it and writing nothing is the same as not + # ticking it -- rather than the model being told the answer is + # "__other__", which is the shape of thing it would try to act on. + parts = [pick for pick in picks if pick != interaction.OTHER] + if interaction.OTHER in picks and typed.get(key): + parts.append(typed[key]) + if parts: + answers[key] = ", ".join(parts) + + # A box with no choice beside it: the approval card's corrected command, + # which is the one place `text.` still stands on its own. + for key, written in typed.items(): + if written and key not in answers and key not in chosen: answers[key] = written - elif kind == "choice" and written: - answers.setdefault(key, written) # Read and recorded *before* resolving: `interaction.wait_for` clears # `generation.pending` in its `finally`, so a moment later there is nothing diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index a34e282..91a8851 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -1377,7 +1377,6 @@ def _ask_items(context, calls: list[dict], arguments: list[dict]) -> list[intera args = arguments[index] 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, @@ -1385,12 +1384,50 @@ def _ask_items(context, calls: list[dict], arguments: list[dict]) -> list[intera 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]), + options=_options_in(asked), + multiple=bool(asked.get("multiple")), ) ) return items +def _options_in(asked: dict) -> tuple[interaction.Option, ...]: + """The choices offered for one question, however they were spelled. + + The schema asks for objects with a `label` and an optional `description`, + and a capable model sends that. A small one sends a list of bare strings -- + which is what the schema asked for until recently and is what most examples + of this pattern look like -- so that is read as a label with no description + rather than refused. Anything else in the list is dropped rather than + stringified, because `{'a': 1}` rendered as a choice is worse than one + choice fewer. + + An option meaning "something else" is **not** added here. It belongs to the + template, which adds it to every question and owns the box behind it; adding + it to the data would make it indistinguishable from one the model wrote. + """ + out: list[interaction.Option] = [] + for raw in asked.get("options") or []: + if isinstance(raw, str): + label, description = raw.strip(), "" + elif isinstance(raw, dict): + label = str(raw.get("label") or raw.get("name") or raw.get("value") or "").strip() + description = str(raw.get("description") or "").strip() + else: + continue + if not label: + continue + out.append( + interaction.Option( + label=label[: interaction.MAX_OPTION_CHARS], + description=description[: interaction.MAX_OPTION_CHARS], + ) + ) + if len(out) >= interaction.MAX_OPTIONS: + break + return tuple(out) + + def _questions_in(args: dict) -> list[dict]: """The questions in one `ask_user` call, however it was spelled. diff --git a/src/lembas/services/interaction.py b/src/lembas/services/interaction.py index 7818e00..d47cbeb 100644 --- a/src/lembas/services/interaction.py +++ b/src/lembas/services/interaction.py @@ -54,6 +54,32 @@ MAX_OPTIONS = 6 # what it learned. MAX_QUESTIONS = 8 +# The value the "Something else" row submits. A sentinel rather than a real +# option, because it is the one choice on the card the model did not write: it +# is added by this code, always, to every question. That is the whole reason the +# model is told never to offer an "Other" of its own -- two of them is one that +# does nothing, and the model's version would have no box behind it. +OTHER = "__other__" + +# How many characters of an option's description are kept. It is a sentence +# explaining a choice, not a paragraph, and it is model output landing in a +# card somebody is meant to read at a glance. +MAX_OPTION_CHARS = 240 + + +@dataclass(frozen=True) +class Option: + """One answer offered for a question. + + A `label` alone reads as a button; the optional `description` is what makes + a real choice possible -- "Rewrite it" and "Patch it" say nothing about + which loses your uncommitted work. Both are model output and are escaped + where they are shown. + """ + + label: str + description: str = "" + @dataclass(frozen=True) class Item: @@ -83,7 +109,18 @@ class Item: # explanation somebody reads as the application's own would be a card # vouching for it. purpose: str = "" - options: tuple[str, ...] = () + options: tuple[Option, ...] = () + # Whether more than one option may be chosen. The model says which, because + # only the model knows whether its options are alternatives ("rewrite or + # patch") or a set ("which of these to include"). Exclusive is the default: + # a radio group offered where checkboxes were meant costs one clarifying + # round, while checkboxes offered for alternatives invite an answer that + # contradicts itself. + multiple: bool = False + # Whether "Something else" is offered, with the box behind it. True for a + # question -- the options are the model's guess at the answers and it can be + # wrong -- and false for an approval, where the choice is Allow or Don't and + # a third way out would mean nothing. allow_free_text: bool = True # Whether `detail` can be corrected before this is allowed. Only where the # detail *is* one argument and can be put back where it came from -- a tool diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py index 97aee48..4be73d5 100644 --- a/src/lembas/services/tools.py +++ b/src/lembas/services/tools.py @@ -1005,9 +1005,20 @@ REGISTRY: dict[str, ToolDef] = { "for their answers before going on. Use it when you genuinely need " "a decision only they can make — which of several approaches to " "take, a detail you cannot infer, permission for something " - "consequential. Offer options when there is a small set of " - "sensible answers; they can always write their own instead. " - "\n\n" + "consequential.\n\n" + "**Always give options.** A question with no options is a blank box, " + "and a blank box asks the person to do the thinking you were meant " + "to do: offer the two to six answers you actually think are " + "plausible, in the order you would recommend them. Do NOT add an " + "option meaning “other”, “something else”, “none of these” or " + "“let me type it” — one is added for you, on every question, with a " + "box behind it. Yours would have no box and would do nothing.\n\n" + "Say whether the options are exclusive. `multiple: false` (the " + "default) is for alternatives, where picking one rules out the " + "rest; `multiple: true` is for a set, where any number may be " + "chosen. Give an option a `description` wherever the label alone " + "does not say what choosing it would mean — that is what makes a " + "real decision possible rather than a guess between two words.\n\n" "Ask everything you need in ONE call: they answer the whole card at " "once and it costs them a single interruption, where asking twice " "in a row costs two. Do not use it for anything you can work out " @@ -1031,14 +1042,43 @@ REGISTRY: dict[str, ToolDef] = { }, "options": { "type": "array", - "items": _STRING, "description": ( - "Up to six answers to offer for this " - "question. Optional." + "The answers to offer, two to six of them. " + "Required. Never include an “other” or " + "“something else” option — one is always " + "added for you." + ), + "items": { + "type": "object", + "properties": { + "label": { + **_STRING, + "description": ( + "The choice itself, in a few words." + ), + }, + "description": { + **_STRING, + "description": ( + "Optional: one line on what " + "choosing this would mean, where " + "the label alone does not say." + ), + }, + }, + "required": ["label"], + }, + }, + "multiple": { + "type": "boolean", + "description": ( + "Whether more than one option may be chosen. " + "False (the default) for alternatives, true " + "for a set." ), }, }, - "required": ["question"], + "required": ["question", "options"], }, }, }, diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 0a7db63..1d4606b 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -494,9 +494,68 @@ border-top: 1px solid var(--border); } .interaction__title { margin: 0; padding: 0; color: var(--ink); font-weight: 500; } -.interaction__options { display: flex; flex-wrap: wrap; gap: var(--sp-2); } +/* Stacked, one per line. A row of chips was fine while an option was two words + and nothing else; an option now carries a description as well, and a row has + nowhere to put the second and no room to read the first. */ +.interaction__options { display: flex; flex-direction: column; gap: var(--sp-2); } .interaction__note { color: var(--ink-faint); font-size: var(--text-xs); } +/* One option: the control, then a label and an optional line under it. The + whole row is the target -- it is a `