diff --git a/CLAUDE.md b/CLAUDE.md index 59265b0..1f968ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,9 +245,51 @@ work: a follower arriving late has no earlier fragments to append to. It also means Markdown is re-rendered whole, which is required anyway -- a list or code fence is only correct once its context exists. -**Stopping sets a flag the producer checks.** `generation.request_stop()`; -whatever arrived is kept and the message is marked `stopped`, which is distinct -from `error`. In-process, so single-worker only. +**Two frames must be able to blank themselves, and the rest must not.** +`reasoning`, `tools` and `render` are only sent when they have something in +them, so a frame can never wipe what is on screen. `metrics`, `status` and +`ask` are sent on every version bump *including empty*, because each has to be +able to clear: an approval card that survived being answered would be a button +you could press twice. + +**Stopping sets a flag the producer checks -- except while it is paused.** +`generation.request_stop()`; whatever arrived is kept and the message is marked +`stopped`, distinct from `error`. In-process, so single-worker only. `cancel` is +read in exactly one place, between streamed chunks, and a reply waiting on an +approval produces no chunks -- so `request_stop` also resolves +`generation.pending`, and that is the wakeup. Without it the Stop button does +nothing at all while a card is on screen, silently. + +**Asking a person is one primitive with three uses.** +`services/interaction.py`: a command waiting to be allowed, a question the model +asked, and "this reply is waiting for you" are all *pause, render a block in the +bubble, wait for a POST, resume*. It pauses a **round, not a call** -- a round's +calls run together under a semaphore, and parking four coroutines on four +separate answers inside that gather would queue them behind each other +invisibly, and hand the reader four cards for commands whose order matters. So +one card covers everything in the round, and `_authorise` returns pre-decided +outcomes keyed by call index, which is what keeps +`zip(calls, outcomes, strict=True)` aligned. + +**One `ask_user` call may carry several questions, and they come back at once.** +Each becomes an `Item` with its own `key`; several items can share an `index` +because they belong to one call, and one tool turn answers them all with each +answer quoted beside its question. Asking one at a time would cost a round trip +and an interruption each, and answering the third would mean having forgotten +the first. `_questions_in` reads the singular form and bare strings too: a small +model sends something close to the schema rather than the schema, and getting it +wrong costs a whole round trip to show a card that says nothing. + +**A paused reply is deliberately not `done`.** That is what lets a page reload +reattach to it. Its *timeout* is what stops it lingering, not `_prune`, which +only drops finished ones -- so `approval_timeout` is clamped to at least a +minute on read, and `_prune` resolves anything whose deadline is long past as a +backstop. + +**Nothing is persisted while paused.** A restart abandons the pending question +along with the reply, and a reload starts the turn afresh -- the model asks +again. That is consistent with "a restart abandons replies in flight", but it +means an approval is not a durable record of consent. **Unread is polled, not pushed.** A browser on another chat has no connection to the one that finished. `/api/chats/unread` returns out-of-band dot spans and diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index e54c8ed..4f4582a 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -675,14 +675,19 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str @router.post("/{chat_id}/interaction/{interaction_id}") async def answer_interaction( + request: Request, db: Db, user: RequiredUser, chat_id: str, interaction_id: str, - choice: str = Form(""), - text: str = Form(""), ) -> Response: - """Answer a question, or allow something, that a reply is waiting on. + """Answer the questions, or allow the action, a reply is waiting on. + + The whole card comes back at once, which is why the raw form is read rather + than declared parameters: one `ask_user` call may have put four questions, + and each carries a chosen option and a box to write something else. Per + question, what was written wins over what was picked -- somebody who typed + in the box after clicking an option meant the typing. `_owned_chat` is the authorisation and it is not decoration: without it any signed-in account that guessed an id would be answering -- and later, @@ -694,8 +699,24 @@ async def answer_interaction( being told plainly. """ chat = _owned_chat(db, chat_id, user.id) + form = await request.form() + + answers: 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: + answers[key] = written + elif kind == "choice" and written: + answers.setdefault(key, written) + answered = generation_service.answer( - chat.id, interaction_id, choice=choice.strip(), text=text.strip() + chat.id, + interaction_id, + verdict=str(form.get("verdict") or "").strip(), + answers=answers, ) response = Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index 9abb6f4..faa86d7 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -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( diff --git a/src/lembas/services/interaction.py b/src/lembas/services/interaction.py index f0530fd..86cf28a 100644 --- a/src/lembas/services/interaction.py +++ b/src/lembas/services/interaction.py @@ -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", diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py index 18863c4..e2583a2 100644 --- a/src/lembas/services/tools.py +++ b/src/lembas/services/tools.py @@ -750,31 +750,48 @@ REGISTRY: dict[str, ToolDef] = { name="ask_user", family=FAMILY_ASK, description=( - "Ask the person you are talking to a question, and wait for their " - "answer 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 type something else instead. Do not use it for " - "anything you can work out yourself, and never ask for a password, " - "a key or any other secret." + "Ask the person you are talking to one or more questions, and wait " + "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" + "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 " + "yourself, and never ask for a password, a key or any other secret." ), parameters=_object( { - "question": { - **_STRING, - "description": "One clear question, in plain language.", - }, - "options": { + "questions": { "type": "array", - "items": _STRING, "description": ( - "Up to six answers to offer as buttons. Optional; they " - "can always write their own." + "The questions to put, answered together. Ask up to " + "about four at a time; more than that is a form, not a " + "conversation." ), + "items": { + "type": "object", + "properties": { + "question": { + **_STRING, + "description": "One question, in plain language.", + }, + "options": { + "type": "array", + "items": _STRING, + "description": ( + "Up to six answers to offer for this " + "question. Optional." + ), + }, + }, + "required": ["question"], + }, }, }, - ["question"], + ["questions"], ), # Never resolved by this runner. The reader answers it, in every # mode, and the loop turns their answer into the outcome -- see diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 8bebd9d..86a7ecb 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -411,8 +411,53 @@ text-transform: uppercase; letter-spacing: 0.06em; } -.interaction__item { display: flex; flex-direction: column; gap: var(--sp-2); } -.interaction__title { margin: 0; color: var(--ink); font-weight: 500; } +.interaction__form { display: flex; flex-direction: column; gap: var(--sp-4); } +/* One block per question. Several go on one card and submit together. */ +.interaction__question { + display: flex; + flex-direction: column; + gap: var(--sp-2); + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} +.interaction__question + .interaction__question { + padding-top: var(--sp-4); + 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); } +.interaction__note { color: var(--ink-faint); font-size: var(--text-xs); } + +/* An option. A radio, so picking one unpicks the last, but shaped like the + button it reads as. */ +.chip { position: relative; display: inline-flex; } +.chip input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + margin: 0; +} +.chip span { + display: inline-flex; + align-items: center; + min-height: var(--control-h); + padding: 0 var(--sp-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-raised); + color: var(--ink-muted); + font-size: var(--text-sm); +} +.chip input:hover + span { background: var(--surface-hover); color: var(--ink); } +.chip input:checked + span { + border-color: var(--accent); + background: var(--surface-active); + color: var(--ink); +} +.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } .interaction__detail { margin: 0; padding: var(--sp-3); diff --git a/src/lembas/web/templates/chat/_interaction.html b/src/lembas/web/templates/chat/_interaction.html index ec7f2ac..67b64f0 100644 --- a/src/lembas/web/templates/chat/_interaction.html +++ b/src/lembas/web/templates/chat/_interaction.html @@ -2,9 +2,9 @@ {# The reply has stopped and is waiting for you. - Two shapes, one mechanism: a question the model asked, and (later) a command + Two shapes, one mechanism: questions the model asked, and (later) a command waiting to be allowed. Everything shown here is model output and is escaped - accordingly -- the question text, the options on the buttons and the command + accordingly -- the questions, the options on the buttons and the command itself all came from a model that may have been reading somebody else's file a moment ago. @@ -12,6 +12,10 @@ asking. A question that looks like it came from the application is a question people answer with things they would not tell a chatbot. + Several questions go on ONE card and come back in ONE submit. Asking them one + at a time would cost a round trip and an interruption each, and answering the + third would mean having forgotten the first. + hx-swap="none" because the SSE stream clears this card the moment the answer lands; swapping a response in here would fight it. #} @@ -21,47 +25,71 @@ The model is asking you
- {% for item in ask.items %} -{{ item.title }}
- {% if item.detail %} -{{ item.detail }}
- {% endif %}
- {% if item.reason %}
- {{ item.reason }}
- {% endif %} -