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
parent ab4ffa2e5a
commit f4bf1bf670
10 changed files with 371 additions and 17 deletions
+27 -1
View File
@@ -288,6 +288,7 @@ def answer(
*,
verdict: str = "",
answers: dict[str, str] | None = None,
reason: str = "",
) -> bool:
"""Resolve whichever running reply is parked on this interruption.
@@ -300,7 +301,7 @@ def answer(
if generation.chat_id != chat_id or pending is None or pending.id != interaction_id:
continue
outcome = verdict if verdict in _VERDICTS else interaction.ANSWER
return pending.resolve(outcome, answers=answers)
return pending.resolve(outcome, answers=answers, reason=reason)
return False
@@ -1629,6 +1630,20 @@ def _not_allowed(item: interaction.Item, reply: interaction.Reply) -> ToolOutcom
Told plainly, and told to stop rather than to try again: a model that reads
"not allowed" as "not allowed *that way*" will spend the rest of the reply
looking for a way round, which is the opposite of what the refusal meant.
A refusal that came with a reason is told differently, and the difference is
the whole point of offering the box. "Ask what they would prefer" is the
right thing to say to a model that has been given nothing to go on, and
exactly the wrong thing to say to one that has just been told -- it spends a
round asking a question whose answer is on the screen above it. So when there
is a reason the model is pointed at it and told to carry on from it, and the
"do not look for a way round" half is kept, because that half is about the
refusal and holds either way.
The reason is the *reader's* words, not a model's and not a machine's, which
is why it is stated as theirs and needs no fence: this is the one thing in a
tool result that is not untrusted. It is bounded at `MAX_REASON_CHARS` when
the reply is built, so nothing here has to think about length.
"""
event = {
"name": item.tool_name,
@@ -1643,6 +1658,17 @@ def _not_allowed(item: interaction.Item, reply: interaction.Reply) -> ToolOutcom
"about to do and why.",
{**event, "status": "error", "error": "Not answered."},
)
if reply.reason:
return ToolOutcome(
f"They declined this, and said why:\n\n{reply.reason}\n\n"
"Take that as their instruction and carry on from it. Do not try the "
"same thing another way, and do not ask them to repeat what they have "
"just told you.",
# On the event as well as in the result, so somebody scrolling back
# through the transcript can see why a step was refused rather than
# only that it was.
{**event, "status": "error", "error": f"Declined: {reply.reason}"},
)
return ToolOutcome(
"They declined this. Do not try it another way — say what you were "
"going to do and ask what they would prefer.",