"Don't" told the model it was refused and nothing else, so it did the one sensible thing left and asked what you would rather -- a whole round spent on something you knew when you pressed the button. "Give reason" opens a box beside it, and what you write goes back with the refusal. The reason changes what the model is *told*, not only what it reads, and that is the whole of the feature. `_not_allowed` branches: given nothing to go on, "say what you were going to do and ask what they would prefer" is right; given a reason it is exactly wrong, because the answer is already on the screen above and the model spends a round asking for it again. So it is pointed at the reason and told to carry on from it. The "do not look for a way round" half is kept either way -- that half is about the refusal and holds regardless. A card-level field rather than `text.<key>`. One card covers everything in the round for the reason the primitive exists, so one reason answers the round; and on an approval card `text.<key>` already means a corrected command, which is a different thing arriving in the same shape. Read only on a refusal, so a reason typed and then abandoned by pressing Allow cannot travel with a permission. Bounded where the Reply is built, so nothing downstream thinks about length, and put on the tool event as well as in the result -- a transcript saying a step was refused without saying why is one you had to have been watching to understand. It is also the one thing in a tool result that is genuinely not untrusted: the reader's own words, stated as theirs, needing no fence. Both halves of the control are in the DOM with one hidden and the textarea disabled while hidden, which is the rule the edit box beside it already states: a field created by a click submits nothing when the click handler fails, and an empty `reason` arriving would have to be told from one somebody cleared. The version bump is not incidental. chat.css changed and the service worker caches it under a name keyed on the version, so without it the first reload serves the old stylesheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""Pausing a reply to ask the person reading it something.
|
|
|
|
Three features turn out to be one mechanism. A command that needs approving, a
|
|
question the model wants answered, and "this reply is waiting for you" are all:
|
|
stop the generation, put an interactive block in the bubble, wait for a POST,
|
|
carry on. So there is one primitive, and approval is a shape of question rather
|
|
than a separate machine.
|
|
|
|
Two things about where it sits matter.
|
|
|
|
**It pauses a round, not a call.** A round's tool 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 the reader would get
|
|
four cards, answerable in any order, for commands whose order matters. So one
|
|
card describes everything in the round that needs a decision, and the calls that
|
|
survive it run concurrently exactly as they did before.
|
|
|
|
**Stop has to keep working.** `generation.cancel` is read in one place, between
|
|
streamed chunks, and there are no chunks while paused. Rather than a second
|
|
poller, `generation.request_stop` resolves the pause directly; see the comment
|
|
there. Nothing in this module reaches back into `services.generation`, which is
|
|
what keeps it testable on its own.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING: # pragma: no cover - annotation only
|
|
from lembas.services.generation import Generation
|
|
|
|
KIND_APPROVAL = "approval"
|
|
KIND_QUESTION = "question"
|
|
|
|
# How a pause ended.
|
|
ALLOW = "allow"
|
|
ALLOW_ALWAYS = "allow_always"
|
|
DENY = "deny"
|
|
ANSWER = "answer"
|
|
CANCELLED = "cancelled" # Stop was pressed while the card was showing
|
|
EXPIRED = "expired" # nobody answered in time
|
|
|
|
# Outcomes that mean "go ahead".
|
|
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
|
|
|
|
# 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
|
|
|
|
# How much of a refusal's reason is carried back to the model. Generous, because
|
|
# this is the reader saying what they want instead and truncating that mid-clause
|
|
# is worse than the tokens it saves -- but bounded, because it lands in a tool
|
|
# result inside a request that already has a window to fit in.
|
|
MAX_REASON_CHARS = 2000
|
|
|
|
|
|
@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:
|
|
"""One thing being asked about: a single question, or one command.
|
|
|
|
`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
|
|
detail: str = ""
|
|
reason: str = ""
|
|
# What the model says this call is for, in its own words -- distinct from
|
|
# `reason`, which is why *we* stopped ("Edit mode asks before anything that
|
|
# runs a command"). Model text, and shown as such: a card carrying an
|
|
# explanation somebody reads as the application's own would be a card
|
|
# vouching for it.
|
|
purpose: 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
|
|
# with no entry in `tool_labels.DETAIL_KEYS` gets a `k=repr(v)` summary that
|
|
# cannot be parsed back, and offering a box that silently changed nothing
|
|
# would be worse than offering none.
|
|
editable: bool = False
|
|
|
|
|
|
@dataclass
|
|
class Interruption:
|
|
"""A reply, stopped, waiting for one answer to cover every item."""
|
|
|
|
id: str
|
|
items: tuple[Item, ...]
|
|
expires_at: float = 0.0
|
|
_future: asyncio.Future | None = field(default=None, repr=False, compare=False)
|
|
|
|
@property
|
|
def kind(self) -> str:
|
|
return KIND_QUESTION if any(i.kind == KIND_QUESTION for i in self.items) else KIND_APPROVAL
|
|
|
|
def resolve(
|
|
self, outcome: str, *, answers: dict[str, str] | None = None, reason: str = ""
|
|
) -> bool:
|
|
"""Complete this pause. Idempotent -- a second answer is ignored.
|
|
|
|
Returns whether this call was the one that answered it, which is what
|
|
the endpoint reports back: a card answered twice (two tabs, a double
|
|
click) should say so rather than pretend.
|
|
"""
|
|
if self._future is None or self._future.done():
|
|
return False
|
|
self._future.set_result(
|
|
Reply(
|
|
outcome=outcome,
|
|
answers=dict(answers or {}),
|
|
reason=reason.strip()[:MAX_REASON_CHARS],
|
|
)
|
|
)
|
|
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 -- except for `reason`.
|
|
|
|
`reason` is why the reader refused, in their own words, and it belongs to
|
|
the *card* rather than to an item. The card already covers everything in the
|
|
round for the reason `interaction` opens with, one verdict answers the lot,
|
|
and somebody who says "not in that directory" is saying it about the round.
|
|
Keeping it off `answers` also keeps it clear of `text.<key>`, which on an
|
|
approval card already means something else entirely -- a corrected command.
|
|
"""
|
|
|
|
outcome: str
|
|
answers: dict[str, str] = field(default_factory=dict)
|
|
reason: str = ""
|
|
|
|
@property
|
|
def permitted(self) -> bool:
|
|
return self.outcome in PERMITTED
|
|
|
|
@property
|
|
def ended(self) -> bool:
|
|
"""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
|
|
) -> Interruption:
|
|
"""An interruption with its future attached, ready to be waited on."""
|
|
return Interruption(
|
|
id=interaction_id,
|
|
items=tuple(items),
|
|
expires_at=time.monotonic() + timeout,
|
|
_future=asyncio.get_running_loop().create_future(),
|
|
)
|
|
|
|
|
|
async def wait_for(
|
|
generation: Generation, interruption: Interruption, *, timeout: float
|
|
) -> Reply:
|
|
"""Park the generation on this interruption until somebody answers.
|
|
|
|
Sets `generation.pending` and touches, so the follower sends the card on its
|
|
next frame; clears both in `finally`, so answering makes it disappear. The
|
|
time spent here accumulates on `generation.waited` and is taken off the
|
|
reply's wall-clock budget -- a reader who thinks for ten minutes about one
|
|
command should not thereby spend the whole allowance.
|
|
"""
|
|
generation.pending = interruption
|
|
generation.touch()
|
|
started = time.monotonic()
|
|
try:
|
|
return await asyncio.wait_for(asyncio.shield(interruption._future), timeout)
|
|
except TimeoutError:
|
|
return Reply(outcome=EXPIRED)
|
|
finally:
|
|
generation.waited += time.monotonic() - started
|
|
generation.pending = None
|
|
generation.touch()
|
|
|
|
|
|
def summarise(items: tuple[Item, ...]) -> str:
|
|
"""What to show in the status line while the card is up."""
|
|
if not items:
|
|
return ""
|
|
if items[0].kind == KIND_QUESTION:
|
|
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…"
|
|
|
|
|
|
__all__ = [
|
|
"ALLOW",
|
|
"ALLOW_ALWAYS",
|
|
"ANSWER",
|
|
"CANCELLED",
|
|
"DENY",
|
|
"EXPIRED",
|
|
"KIND_APPROVAL",
|
|
"KIND_QUESTION",
|
|
"MAX_OPTIONS",
|
|
"MAX_QUESTIONS",
|
|
"MAX_REASON_CHARS",
|
|
"PERMITTED",
|
|
"Interruption",
|
|
"Item",
|
|
"Reply",
|
|
"build",
|
|
"summarise",
|
|
"wait_for",
|
|
]
|