Files
LLeMbas/src/lembas/web/templates/chat/_interaction.html
T
Jaroslav Beneš 6fb260892f Correcting a command before allowing it
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>
2026-08-04 08:40:51 +02:00

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>