Agent chats run commands, and stop to ask first
The four tools an agent chat has -- shell_run, file_read, file_write, file_list -- and the mode table wired into the loop that decides which of them stop for approval. Verified end to end against a real Kali container over SSH: the card shows the command, allowing it runs it there, and the file it writes is visible from outside. The mode is enforced in `_authorise`, in the generation loop, server-side, keyed on each tool's declared risk. Not in the prompt: a model is told which mode it is in so it behaves sensibly, but everything it reads -- a web page, a README, the output of the last command -- is untrusted, and a rule written only into a system message is one a poisoned file can argue with. Within an agent chat every call goes through the table, including the built-in ones, because notes_edit writes and Plan mode meaning "look but do not touch" has to mean that too. Two things this turned up. The runners re-check the mode as a backstop, and that backstop refused the very thing a person had just approved -- the mode says "ask", and asking was exactly what happened. Approval is now threaded per call, on a copy of the context, because a round runs its calls together and only some of them were allowed. And the harness said nothing at all, because `registry` maps an offered tool *name* back to a family and did not know the agent tools existed. So shell_run resolved to no family and the fragment naming the machine, the directory and the mode was never admitted. The same omission cost custom tools their guidance once already; there is a test for it now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -893,3 +893,31 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--success) 25%, transparent);
|
||||
}
|
||||
.unread-dot[hidden] { display: none; }
|
||||
|
||||
/* --- Agent chats ----------------------------------------------------------- */
|
||||
/* In the header, not the settings panel: the mode is the difference between
|
||||
being interrupted and not, and it is looked at constantly. */
|
||||
.agent-bar { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.agent-bar__where {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
max-width: 14rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Chat or Agent, on the new-chat composer. */
|
||||
.composer__kind {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-2) var(--sp-2);
|
||||
}
|
||||
.composer__kind-agent { display: flex; gap: var(--sp-2); flex: 1 1 18rem; min-width: 0; }
|
||||
.composer__kind-agent .input { flex: 1; min-width: 0; }
|
||||
.select--sm, .input--sm { height: calc(var(--control-h) - 0.35rem); font-size: var(--text-xs); }
|
||||
|
||||
@@ -477,3 +477,52 @@ document.addEventListener("lembas:notify", function (event) {
|
||||
document.addEventListener("DOMContentLoaded", watch);
|
||||
document.body && document.body.addEventListener("htmx:afterSettle", sync);
|
||||
})();
|
||||
|
||||
/*
|
||||
Chat or Agent, on the new-chat composer.
|
||||
|
||||
Two radios rather than a checkbox because they are two kinds of conversation,
|
||||
not a setting on one -- and the choice is permanent, so it should read as a
|
||||
fork. Picking Agent reveals the connection and directory; picking Chat hides
|
||||
them and sets the hidden `kind` back, so a form submitted either way carries
|
||||
exactly what it means.
|
||||
*/
|
||||
(function () {
|
||||
function wire(root) {
|
||||
var kind = root.querySelector("#chat-kind") ||
|
||||
root.parentNode.querySelector("#chat-kind");
|
||||
var extra = root.querySelector(".composer__kind-agent");
|
||||
var picker = root.querySelector('select[name="ssh_profile_id"]');
|
||||
var dir = root.querySelector('input[name="project_dir"]');
|
||||
if (!kind || !extra) return;
|
||||
|
||||
function sync() {
|
||||
var chosen = root.querySelector('input[name="kind_choice"]:checked');
|
||||
var agent = chosen && chosen.value === "agent";
|
||||
kind.value = agent ? "agent" : "chat";
|
||||
extra.hidden = !agent;
|
||||
}
|
||||
|
||||
root.addEventListener("change", function (event) {
|
||||
if (event.target.name === "kind_choice") sync();
|
||||
// Following the profile's own directory is a convenience, not a rule:
|
||||
// once someone has typed their own it is left alone.
|
||||
if (event.target === picker && dir && !dir.dataset.touched) {
|
||||
var option = picker.options[picker.selectedIndex];
|
||||
dir.value = (option && option.dataset.dir) || "";
|
||||
}
|
||||
});
|
||||
if (dir) dir.addEventListener("input", function () { dir.dataset.touched = "1"; });
|
||||
sync();
|
||||
}
|
||||
|
||||
function scan() {
|
||||
document.querySelectorAll("[data-agent-picker]").forEach(function (el) {
|
||||
if (!el.dataset.wired) { el.dataset.wired = "1"; wire(el); }
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", scan);
|
||||
document.body && scan();
|
||||
document.addEventListener("htmx:afterSettle", scan);
|
||||
})();
|
||||
|
||||
@@ -54,6 +54,38 @@
|
||||
<input type="hidden" name="temporary" value="true">
|
||||
{% endif %}
|
||||
|
||||
{# Chat or Agent, chosen once. There is no switching afterwards: the
|
||||
tools offered, the harness and the approval loop all differ, so a
|
||||
conversation whose earlier turns ran somewhere else is not one
|
||||
conversation. Only shown when picking Agent would lead anywhere. #}
|
||||
{% if not chat and agent_profiles %}
|
||||
<input type="hidden" name="kind" value="chat" id="chat-kind">
|
||||
<div class="composer__kind" data-agent-picker>
|
||||
<label class="chip">
|
||||
<input type="radio" name="kind_choice" value="chat" checked>
|
||||
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
||||
</label>
|
||||
<label class="chip">
|
||||
<input type="radio" name="kind_choice" value="agent">
|
||||
<span>{{ icon("server", "icon--sm") }} Agent</span>
|
||||
</label>
|
||||
|
||||
<span class="composer__kind-agent" hidden>
|
||||
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
|
||||
{% for profile in agent_profiles %}
|
||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||
{{ 'disabled' if not profile.verified }}>
|
||||
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="input input--sm input--mono" name="project_dir"
|
||||
value="{{ agent_profiles[0].default_dir }}"
|
||||
aria-label="Project directory" placeholder="/project">
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="composer__row">
|
||||
{% if can.get("files.upload") %}
|
||||
{# A menu rather than the file picker straight away: there are four ways
|
||||
|
||||
@@ -24,6 +24,26 @@
|
||||
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
||||
</h1>
|
||||
|
||||
{# The mode is the one agent setting that changes mid-chat: it decides
|
||||
what gets asked about, not what the conversation is. In the header
|
||||
rather than the settings panel because it is looked at constantly --
|
||||
it is the difference between being interrupted and not. #}
|
||||
{% if chat and chat.kind == "agent" %}
|
||||
<form class="agent-bar" hx-post="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change">
|
||||
<span class="agent-bar__where" title="{{ chat.project_dir }}">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
{{ agent_profile.name if agent_profile else "connection missing" }}
|
||||
</span>
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Mode">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}"
|
||||
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
A link, not a script: the flag lives in the URL, so it survives a
|
||||
|
||||
Reference in New Issue
Block a user