Refusing can say why, and the why is an instruction

"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>
This commit is contained in:
Jaroslav Beneš
2026-08-05 12:44:54 +02:00
co-authored by Claude Opus 5
parent 102531c8ba
commit 9f5ff72e32
8 changed files with 345 additions and 17 deletions
+26 -3
View File
@@ -66,6 +66,12 @@ OTHER = "__other__"
# 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:
@@ -143,7 +149,9 @@ class Interruption:
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) -> bool:
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
@@ -152,7 +160,13 @@ class Interruption:
"""
if self._future is None or self._future.done():
return False
self._future.set_result(Reply(outcome=outcome, answers=dict(answers or {})))
self._future.set_result(
Reply(
outcome=outcome,
answers=dict(answers or {}),
reason=reason.strip()[:MAX_REASON_CHARS],
)
)
return True
@@ -162,11 +176,19 @@ class Reply:
`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.
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:
@@ -239,6 +261,7 @@ __all__ = [
"KIND_QUESTION",
"MAX_OPTIONS",
"MAX_QUESTIONS",
"MAX_REASON_CHARS",
"PERMITTED",
"Interruption",
"Item",