6fb260892f
An approval card was Allow, Always, or Don't. A model proposing the right command with one flag wrong therefore cost a whole round trip to explain in prose. There is an Edit button on it now. Where the edit lands is the whole of the feature, and it is one line. `arguments` is the list `_run_calls` hands to `run_tool` as `parsed=`, and `run_tool` never re-parses -- so writing into it inside `_authorise` is the only mutation the runner can see. Editing the Item would do nothing: it is frozen and display-only. Two things had to move with it. The raw `call["arguments"]` string is rewritten beside the parsed dict, and the assistant turn is now built *after* `_authorise` rather than before it -- the old order told the model it ran what it proposed while something else ran, and every later round would have reasoned from a transcript that was quietly false. And `_remember_always` reads the edit, or "always allow this" would store a standing permission for a command nobody approved; it still derives the pattern itself through `policy.subject`, which yields nothing for a composed command line. Nothing is re-checked against the mode or the lists, and that is not a shortcut. The deny list resolves to ASK rather than to a refusal -- it means "always ask about this" -- so a person who has typed the command and pressed Allow is exactly the asking it was demanding, and re-asking would put the same card up with no way past it. It is the line the terminal panel already draws. The box is only offered where the detail *is* an argument and can be put back: a tool with no entry in `tool_labels.DETAIL_KEYS` gets a `k=repr(v)` summary, and a box there would silently change nothing. Both halves are always in the DOM with one hidden, rather than the field being created on click -- a field that does not exist until a handler runs is a field that submits nothing if the handler fails, and this one decides what runs on somebody's machine. The transcript says "edited by you". Attributing somebody's own typing to a model is the same misattribution as the other way round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
5.9 KiB
HTML
140 lines
5.9 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
The reply has stopped and is waiting for you.
|
|
|
|
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 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.
|
|
|
|
Deliberately attributed to the model rather than styled as if LLeMbas were
|
|
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.
|
|
#}
|
|
<div class="interaction interaction--{{ ask.kind }}">
|
|
<p class="interaction__from">
|
|
{{ icon("sparkle", "icon--sm") }}
|
|
<span>The model is asking you</span>
|
|
</p>
|
|
|
|
<form class="interaction__form"
|
|
hx-post="/api/chats/{{ chat_id }}/interaction/{{ ask.id }}"
|
|
hx-swap="none">
|
|
|
|
{% if ask.kind == "question" %}
|
|
{% for item in ask.items %}
|
|
<fieldset class="interaction__question">
|
|
<legend class="interaction__title">{{ item.title }}</legend>
|
|
|
|
{% if item.options %}
|
|
<div class="interaction__options">
|
|
{% for option in item.options %}
|
|
<label class="chip">
|
|
<input type="radio" name="choice.{{ item.key }}" value="{{ option }}">
|
|
<span>{{ option }}</span>
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if item.allow_free_text %}
|
|
{# Never type="password". A model talked into asking for a credential
|
|
must not be handed a field that looks built for one, and a chat
|
|
transcript is not a place to keep secrets. #}
|
|
<input class="input" type="text" name="text.{{ item.key }}" autocomplete="off"
|
|
placeholder="{{ 'Or write your own answer…' if item.options else 'Your answer…' }}">
|
|
{% endif %}
|
|
</fieldset>
|
|
{% endfor %}
|
|
|
|
<div class="interaction__actions">
|
|
<button class="btn btn--primary" type="submit">
|
|
{{ icon("send", "icon--sm") }}
|
|
{{ "Answer" if ask.items | length == 1 else "Send answers" }}
|
|
</button>
|
|
<span class="interaction__note">
|
|
{%- if ask.items | length > 1 %}All of them at once. {% endif -%}
|
|
Leave any blank to skip it.
|
|
</span>
|
|
</div>
|
|
|
|
{% else %}
|
|
{% for item in ask.items %}
|
|
<div class="interaction__question" x-data="{ editing: false }">
|
|
<p class="interaction__title">{{ item.title }}</p>
|
|
{% if item.purpose %}
|
|
{# The model's own account of what this is for, above the thing itself.
|
|
Deliberately separate from `reason` below, which is *our* reason for
|
|
stopping — attributed, so nobody reads an explanation the model wrote
|
|
as the application vouching for the command. #}
|
|
<p class="interaction__purpose">It says: {{ item.purpose }}</p>
|
|
{% endif %}
|
|
{% if item.detail %}
|
|
{% if item.editable %}
|
|
{#
|
|
The command, correctable before it runs. A model proposing the right
|
|
thing with one flag wrong is the common case, and Allow-or-Don't
|
|
makes that a round trip to explain in prose.
|
|
|
|
Both halves are always in the DOM and one is hidden, rather than the
|
|
box being created when Edit is pressed: a field that does not exist
|
|
until a click is a field that submits nothing if the click handler
|
|
ever fails, and this one decides what runs on somebody's machine.
|
|
The textarea is disabled while hidden so an untouched card cannot
|
|
post a `text.` field at all — that field means "this was edited",
|
|
and an empty one arriving would be indistinguishable from a command
|
|
somebody cleared.
|
|
#}
|
|
<div x-show="!editing">
|
|
<pre class="interaction__detail">{{ item.detail }}</pre>
|
|
<button class="btn btn--sm" type="button"
|
|
@click="editing = true; $nextTick(() => $refs.edit{{ item.key }}.focus())">
|
|
{{ icon("pencil", "icon--sm") }} Edit
|
|
</button>
|
|
</div>
|
|
<div x-show="editing" x-cloak>
|
|
<label class="visually-hidden" for="edit-{{ item.key }}">
|
|
Change this before it runs
|
|
</label>
|
|
<textarea class="textarea interaction__edit" id="edit-{{ item.key }}"
|
|
name="text.{{ item.key }}" rows="3" spellcheck="false"
|
|
x-ref="edit{{ item.key }}"
|
|
:disabled="!editing">{{ item.detail }}</textarea>
|
|
<p class="interaction__reason">
|
|
Allow runs what is in the box. It is not checked against this
|
|
chat's rules again — you typed it.
|
|
</p>
|
|
</div>
|
|
{% else %}
|
|
<pre class="interaction__detail">{{ item.detail }}</pre>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if item.reason %}
|
|
<p class="interaction__reason">{{ item.reason }}</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
|
|
<div class="interaction__actions">
|
|
<button class="btn btn--primary" type="submit" name="verdict" value="allow">
|
|
{{ icon("check", "icon--sm") }} Allow
|
|
</button>
|
|
<button class="btn" type="submit" name="verdict" value="allow_always">
|
|
Always allow this
|
|
</button>
|
|
<button class="btn btn--danger" type="submit" name="verdict" value="deny">
|
|
{{ icon("x", "icon--sm") }} Don't
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
</form>
|
|
</div>
|