b602657450b297c7441efbdf2711d357a044fac4
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a5fa982ae3 |
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> |
||
|
|
bc141eae10 |
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> |
||
|
|
374982174f |
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> |
||
|
|
ecb52e9978 |
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> |
||
|
|
7456525d19 |
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>
|