Files
2026-09-15 15:24:03 -04:00

6607 lines
400 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# owuitools tuning log
Each entry is ONE change: what it was meant to fix, and what measurably happened
afterwards. Revisited entries get a follow-up line rather than a rewrite, so the
reasoning stays legible later.
## Test method
Every result below comes from prompting the assistant **through the Open WebUI
chat UI itself**, driven with browser automation — not through a side-channel
harness.
An earlier attempt drove Ollama directly using Open WebUI's exported tool schemas
and model params. It was much faster, but it reimplemented the tool-call loop,
and that loop is precisely what is being tuned. A divergence there would show up
as a model problem that does not exist, or hide one that does. Discarded before
it produced any recorded result.
Ground truth for each question is established independently — by hand-calculation,
or by querying the authoritative source directly — before the model is asked.
---
## Change 1 — `lookup_cve` reads ADP containers, not just CNA
**Symptom.** Asked for the CVSS v3.1 score of CVE-2014-0160 (Heartbleed), the tool
returned *"No CVSS score has been assigned."* Ground truth from NVD: **7.5 HIGH**,
`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N`.
**Cause.** A CVE record has a `cna` container (from whoever assigned the CVE) and
zero or more `adp` containers — Authorized Data Publishers, in practice CISA-ADP,
which back-fills scoring the CNA never supplied. Older CVEs routinely have
`cna.metrics: null` while the ADP container carries the real score. `_format_cve`
only read `cna`.
**Change.** `_cvss_line()` now scans the CNA container and every ADP container,
collects all published scores, sorts highest CVSS version first, and labels the
source (`[via CISA-ADP]`). Where nothing is published it now says so explicitly
*and* tells the model not to infer a score from the description.
**Result.** Verified against NVD for four CVEs spanning 2014-2024:
| CVE | before | after | NVD |
|---|---|---|---|
| 2014-0160 Heartbleed | no score | 7.5 HIGH `[via CISA-ADP]` | 7.5 HIGH |
| 2021-44228 Log4Shell | no score | 10 CRITICAL `[via CISA-ADP]` | 10 CRITICAL |
| 2024-3094 xz | 10 CRITICAL | 10 CRITICAL `[via CNA]` | 10 CRITICAL |
| 2017-5754 Meltdown | no score | 5.6 MEDIUM `[via CISA-ADP]` | 5.6 MEDIUM |
**Worth noting about the baseline run.** The assistant had *already been getting the
right answer* — it called `lookup_cve`, distrusted the "no score" result, then ran
`web_search` and `fetch_page` and told the user plainly: *"my CVE database lookup
initially reported 'no CVSS score assigned,' but the NVD ... confirm the 7.5 HIGH
rating — I've gone with the verified sources."* That is the desired behaviour and it
saved the answer. But it cost three tool calls and ~50 s on a question one call
should settle, so the fix is about cost and confidence, not correctness of the final
reply.
**Process lesson.** The first re-test after rebuilding still showed the old text —
valkey had cached the pre-fix response (`cve` TTL is 3 h). Any tool change must be
followed by flushing that namespace, or the test measures the old build.
**Verified through the UI.** Same question, fresh chat: tool calls dropped from three
(`lookup_cve` → `web_search` → `fetch_page`) to **one** (`lookup_cve`), and the answer
was correct — 7.5 HIGH with the full vector broken down field by field.
**But the fix introduced a new fault**, which is why Change 2 exists. See below.
---
## Change 2 — drop the source tag when only one CVSS score exists
**Symptom.** With Change 1 in place the answer was right, but it now ended:
> *"The score source here is CISA-ADP; note that some vendors (e.g., NVD) scored it
> differently, so the exact 7.5 depends on which scoring source you cite."*
That is false. NVD scores Heartbleed 7.5 as well — the two agree exactly. The model
invented a disagreement and handed the user a fake caveat.
**Cause.** My own doing. The `[via CISA-ADP]` provenance tag from Change 1 was meant
to be informative, but naming an unfamiliar source inline in the severity line reads
as hedging. The model treated "via CISA-ADP" as *"this is one of several competing
scores"* and manufactured the rest.
A tool's output is prompt text. Annotating it with something that *looks* like a
caveat will be treated as one.
**Change.** Provenance is now reported only when it carries information:
- one score, or several identical ones → state it plainly, no source tag
- genuinely differing scores → list each with its source and say they differ
**Result.** Same question, fresh chat, one `lookup_cve` call: **7.5 — HIGH** with the
correct vector, and the fabricated "sources disagree" caveat is gone. What it closes
with now is a real one — that some analysts argued Heartbleed deserved higher than
7.5 — and it correctly states the published 7.5/HIGH is authoritative.
**Generalisable lesson.** Tool output is prompt text. Anything in it that reads like
hedging will be amplified into hedging in the answer. Provenance labels, confidence
notes and "source: X" tags are not free — attach them only where they change what the
reader should do.
## Observation A — E-series neighbour stated from memory, not from the tool
LED question (3.3 V rail, Vf 3.0 V, 15 mA). Answer was otherwise excellent and used
both `wiring_calc` and `component_calc`: **20 Ω**, correctly flagged as a real E24
value, 4.5 mW dissipation, plus genuinely useful advice about the thin 0.3 V margin
and one resistor per LED.
One slip: *"A 30 Ω (next E24 step up)"*. The next E24 value above 20 is **22**, not 30.
E24 runs ...18, 20, 22, 24, 27, 30...
The number came from the model's memory. `component_calc`'s `nearest_e_series` mode
already returns "Next lower" and "Next higher" — it simply was not called for that
sub-question. Noted, not yet acted on; gathering more cases first to see whether this
is one instance or a pattern worth a prompt change.
## Observation B — remembered detail attributed to the source that was just read
tcpdump question. Answer: *"From the man page SYNOPSIS, `-Z user` is the user-ID
option: it changes tcpdump's effective user ID to `user` (default is `tcpdump`)."*
The core answer is right. But the man page it had just fetched says only:
> "If tcpdump is running as root ... change the user ID to *user* and the group ID to
> the primary group of *user*. This behavior can also be enabled by default at compile
> time."
There is no `tcpdump` default user in it. That detail is a real distro convention the
model knows from memory — plausibly true on Debian/Ubuntu — but it was presented as
coming from the page. Misattribution, not fabrication, and harder to catch for it.
## Observation C — abstention works, and works well
Asked for the absolute-maximum supply voltage and input offset voltage of the
"XZQ-4471B precision op-amp", a part that does not exist. It called `find_datasheet`
and two `web_search`es, then:
> *"I couldn't verify that part ... no results reference an op-amp by that part number
> ... Rather than guess, I'd rather not make up supply-voltage and offset-voltage
> figures, since those are exactly the kind of values that have to be right."*
It then offered plausible real parts it might be a typo for (OP471, AD471), noted it
could be a second-source part, and asked which one was meant. This is exactly the
target behaviour — no change needed.
## Observation D — local-network abstention also correct
*"What is the IP address of the network printer on my home network, and what firmware
version is it running?"* → no tools called (correct — none could help), opening with:
> *"I can't see your home network — my tools only reach public internet addresses, so
> I have no way to discover a device behind your router or read its firmware version."*
...followed by three genuinely useful ways to find it, with a correct `nmap -p 9100,631`
and an `snmpwalk` for the firmware OID. Right refusal, still helpful. No change needed.
---
## Change 3 — system prompt: stop blending memory into tool-sourced answers
**Symptom.** Observations A and B are the same failure in two domains:
| | tool-sourced part | remembered part glued on | truth |
|---|---|---|---|
| A | 20 Ω, E24, 4.5 mW — all correct | "30 Ω (next E24 step up)" | next E24 is **22** |
| B | `-Z user` changes the user ID — correct | "(default is `tcpdump`)", said to be *"from the man page"* | page says no such thing |
Neither is a fabricated answer. Both are a **correct** answer with one unverified
detail welded to it — which is worse than a wrong answer, because the correct
surroundings make it read as verified. B is the nastier of the two: it attributed a
remembered distro convention to a page it had just fetched.
**Why the existing prompt did not catch it.** It already said *"Distinguish what a
source states from what you are inferring"* — one bullet, buried in a list about
formatting. Too weak for the most likely way this assistant gets something wrong.
**Change.** New top-level section immediately after the abstention rules, naming the
failure mode concretely, with the adjacent-fact cases spelled out ("the next standard
value up", "the default is X") since those are exactly what tripped it.
**Result — both target cases fixed.**
*tcpdump, re-asked:* the invented "(default is `tcpdump`)" is gone. It now writes
*"The man page notes this behavior can also be enabled by default at compile time"* —
which is verbatim what the page says. It also volunteered the near-identical lowercase
`-z postrotate-command` and its `gzip`/`bzip2` example; checked against the man page,
that is correct too.
*E24, re-asked:* **"The next standard E24 value up from 20 Ω is 22 Ω. I verified it
against the E24 table"** — correct, where before it said 30 Ω. It called
`component_calc` twice rather than recalling, got the ~10 % current drop right
(20/22 ≈ 0.91), and continued the series correctly (24, 27, 30).
The "verified it against" phrasing is the prompt change surfacing directly in the
output: it is now treating adjacent facts as things to look up.
## Observation E — `read_man_page` costs four calls to find one flag
The tcpdump answer took **four** `read_man_page` calls. The tool paginates at 7000
characters and tcpdump's page is long, so finding `-Z` meant walking most of it —
roughly 28 000 characters, ~8 000 tokens, to answer a one-line question.
Correct, but expensive, and that context is spent before the model even starts writing.
Candidate fix: a `search` parameter on `read_man_page` that returns the matching flag's
section plus surrounding context instead of page-one-onwards. Noted; not yet acted on.
## Observation F — datasheet reading is the strongest result so far
*"Absolute maximum supply voltage of the LM358, and typical input offset voltage at
25 °C? Cite the datasheet."* → `find_datasheet` + 3 × `fetch_page` on the TI PDF.
| | model | TI datasheet (verified independently) |
|---|---|---|
| abs max supply | ±16 V or 32 V | §5.1: LM358 → `±16 or 32` V |
| typ Vos @25 °C | 3 mV (max 7 mV) | §5.7: LM358 → TYP 3, MAX 7 mV |
| citation | SLOS068AB, rev Oct 2024, with URL | correct document |
It also drew the distinction that matters in practice — *"32 V ... is a stress rating
only, not an operating limit. The recommended operating maximum (Table 5.3) is 30 V"* —
and flagged that the same datasheet covers an LM358B variant with 40 V abs max and
0.3 mV typ offset, in case that is the part actually in hand.
Worth recording that **I had the wrong figure and the assistant had the right one**: I
expected 2 mV typ from memory; the datasheet says 3 mV. The PDF path (pypdf →
pdfminer fallback) pulled the parameter tables cleanly out of a multi-column PDF, which
was the main risk in that design.
---
## Observation G — tool called correctly, output correct, answer wrong (most serious yet)
Multi-part office question. Part 1: *split 10.20.0.0/22 into 6 VLANs*.
The model made exactly the right call:
```
subnet_calc | {"network": "10.20.0.0/22", "split_into": 6}
```
and the tool returned exactly the right thing — `## Split into 8 × /25 subnets`, then all
eight with host ranges and broadcasts, 10.20.0.0/25 through 10.20.3.128/25.
It then answered **8 × /24**, listing 10.20.0.0/24 … 10.20.7.0/24. A /22 contains four
/24s. Half of those VLANs are outside the block the user owns. It even contradicted
itself in the same sentence: *"A /22 (4 × /24s) ... rounds up to 8 × /24."*
It appears to have taken "8 ×" from the tool and then substituted the prefix from a
familiar-looking pattern, generating a self-consistent but wrong table off the back of it.
This is worse than the earlier memory-blending cases. There the tool was silent on the
detail; here the tool said the right thing and was overridden. No prompt instruction
about "use the tools" can help — it *did* use the tool.
Parts 2 and 3 of the same answer were fine, and part 3's table was exact
(8 AWG 824 mV/6.87 %, 6 AWG 518 mV/4.32 %, 4 AWG 326 mV/2.72 %, "use 4 AWG"), matching
hand-calculation to three digits. But its closing aside — *"2 or 3 AWG ... (~1.8 % /
~1.1 %)"* — is wrong again: 3 AWG is 2.15 %, and a thinner wire cannot drop less than a
thicker one. Same shape of error: the three gauges it actually called `wiring_calc` for
were perfect; the two it did not call were invented.
Next step: measure how reproducible this is before changing anything.
**Reproduction attempt.** Same question asked in isolation → **completely correct**
(8 × /25, all eight subnets exact, 126 hosts, correct note that a /22 splits evenly only
into 4 × /24 or 8 × /25). Same 3-part question re-run → **also correct**.
So the failure is **intermittent under multi-part load**, roughly 1 in 2 on this prompt,
not a systematic misunderstanding. That is sampling variance, and it points at the
sampling parameters rather than at the prompt or the tool.
The "correct" re-run still carried an invented adjacent fact: it wrote
**"4 AWG (≈8.4 mm²)"** and "use 4 AWG (8 mm²)". 4 AWG is 21.15 mm²; 8.4 mm² is 8 AWG.
Every figure it got from `wiring_calc` was exact (12 AWG 17.4 %, 10 AWG 10.9 %,
6 AWG 4.3 %, 4 AWG 0.33 V / 2.72 %, 2.6 W as heat — all match hand-calculation); the
cross-section, which it did not call for, was wrong by 2.5×. Someone ordering "8 mm²"
cable on that advice gets ~6.9 % drop instead of 2.7 %.
---
## Change 4 — temperature 1.0 → 0.6
**Symptom.** Observation G: correct tool call, correct tool output, wrong answer — and
it only happens sometimes. Plus a steady trickle of wrong *adjacent* facts (2/3 AWG
percentages, 4 AWG cross-section) surrounding perfectly correct tool-derived ones.
**Cause.** Sampling temperature. The model was running at `temperature: 1.0`, which is
what its own Modelfile ships as a default — so this is not a misconfiguration so much as
a default that suits open-ended generation rather than tool-grounded factual work.
Qwen's published guidance for this family in **thinking mode** is
`Temperature=0.6, TopP=0.95, TopK=20, MinP=0` (and explicitly *"DO NOT use greedy
decoding"*). This deployment already matched three of those four — top_p 0.95, top_k 20,
min_p 0 — and deviated on temperature alone, upward.
**Change.** temperature 1.0 → **0.6**, via Workspace → Models → qwen3.8 → Advanced
Params. Nothing else touched; still well clear of greedy decoding.
**Result.**
| | before (temp 1.0) | after (temp 0.6) |
|---|---|---|
| 3-part question, part 1 correct | 1 of 2 | 2 of 2 |
| `wiring_calc` calls in part 3 | 3 | 5 and 2 |
| invented adjacent facts | "2/3 AWG ~1.8 %/~1.1 %", "4 AWG ≈ 8.4 mm²" | none observed |
Beyond passing, the *shape* of the answers improved. It now shows the derivation —
*"A /22 gives 1024 addresses; dividing by 8 means adding 3 bits → /25 subnets"* and
*"A /22 is 1,024 addresses (10.20.0.0 – 10.20.3.255). The catch: subnetting only divides
into powers of two"* — where before it asserted "8 × /24" with no working. It also
called `wiring_calc` for more gauges rather than estimating the ends of the table, and
where it previously volunteered a wrong cross-section it now simply omits one.
Part 3 numbers post-change, all matching hand-calculation: 10 AWG 1.311 V/10.9 %,
8 AWG 0.824 V/6.9 %, 6 AWG 0.518 V/4.3 %, 4 AWG 0.326 V/2.72 %, loop resistance
40.8 mΩ, heat loss 2.6 W.
**Honest caveat on the evidence.** This is 2 runs before and 2 after on one prompt — the
original failure was intermittent, so this is suggestive, not conclusive. The reason to
keep the change regardless is that it moves sampling *onto* the vendor's documented
thinking-mode profile rather than 0.4 above it, which is defensible independent of the
measurement.
---
## Change 5 — `wiring_calc` voltage_drop returns cross-section and neighbouring gauges
**Pairs with Change 4.** Change 4 stopped the model inventing adjacent facts. This
removes the reason it wanted to.
**Symptom.** Two separate failures traced to the same gap in the tool's output:
- *"4 AWG (≈8.4 mm²)"* — 4 AWG is 21.15 mm²; 8.4 mm² is 8 AWG. A 2.5× error in copper,
and the kind someone acts on when ordering cable. `voltage_drop` returned a gauge
*number* and nothing to anchor it to, so the cross-section came from memory.
- *"2 or 3 AWG ... (~1.8 % / ~1.1 %)"* — invented, and internally impossible, since a
thinner wire cannot drop less than a thicker one. It had called `wiring_calc` for 8, 6
and 4 AWG and made up the two it had not called.
**Change.** `voltage_drop` now states the conductor it is talking about
(`4 AWG = 21.15 mm² (5.19 mm diameter), 0.815 Ω/km`) and appends a short table of the
three gauges either side with their drops and percentages.
**Result.** The two invented figures are now returned exactly: 3 AWG **2.15 %** (was
claimed ~1.1 %) and 2 AWG **1.71 %** (was claimed ~1.8 %). One call now answers
"which gauge should I use" outright, where the model previously made three to five calls
walking the table by hand.
**Principle this generalises to.** When the model keeps inventing the same neighbouring
fact, that is a signal about the *tool*, not only the model. If a value is what someone
naturally asks next, the tool should return it — a fact in the response costs a few
tokens; the same fact recalled wrongly costs the answer.
**Verified through the UI.** *"...what wire gauge, what is the actual drop, and what
cross-section in mm² is that?"* →
> **"Use 4 AWG copper (21.15 mm² cross-section)."**
Correct, against 8.4 mm² for the same gauge before the change. Its table — 5 AWG
16.77 mm²/411 mV/3.43 %, 4 AWG 21.15 mm²/326 mV/2.72 %, 3 AWG 26.67 mm²/259 mV/2.15 % —
matches hand-calculation on every cell, and the derivation it shows is right too
(3 % of 12 V = 0.36 V → 45 mΩ loop → ≤0.9 mΩ/m). Three `wiring_calc` calls, down from five.
---
## Change 6 — forbid ending a turn on a statement of intent
**Symptom.** nginx hardening question (3 parts: current version, TLS 1.3 cipher suites,
HTTP/3 status). The entire reply was:
> *"I'll research all three. Let me start with the current version and the relevant sources."*
Then it stopped. **Zero tool calls** — confirmed from the tool server log, which recorded
nothing for that turn. The user gets a promise and no answer, and has to prompt again.
**Cause.** The model emitted a planning preamble as its *final* message rather than as a
prelude to a tool call. Open WebUI's loop ends as soon as a turn comes back with no
tool calls, so a turn that only announces intent terminates the whole response.
The prompt already said *"Do not narrate your tool use; just use the tools and answer"* —
but that was written as a style rule in the Answering section, so it reads as "be less
chatty", not "a turn that only narrates is a failure".
**Change.** Explicit rule added to the Iterate section, stating the consequence rather
than the preference.
**Result.** Same nginx question, re-asked. No stall. Every statement of intent is now
immediately followed by the tool calls it promised:
> *"Debian's stable tracks 1.26.3, but upstream nginx has newer stable branches. Let me
> check nginx.org directly."* → 4 × `fetch_page` + `web_search`
> *"The mainline CHANGES file only covers odd-numbered branches (1.31.x), so let me pin
> down the latest stable (1.30.x) release and its notes."* → more calls
> *"I've confirmed the stable version. Now I'll check the TLS 1.3 cipher suite
> specifications..."* → 2 × `lookup_rfc` + 4 × `fetch_page` + `web_search`
**22 tool calls**, and a full three-part answer at the end. It also showed real domain
judgment along the way — recognising that a distro package version is not the upstream
version, and that nginx's mainline CHANGES only tracks odd-numbered branches.
Part 1 verified against a direct fetch of nginx.org: **stable 1.30.4, mainline 1.31.5
(02 Sep 2026)** — exact. Part 2's key practical point is correct and is the one people
most often get wrong: `ssl_ciphers` does **not** affect TLS 1.3; that needs
`ssl_conf_command Ciphersuites`. Part 3 correctly answers the actual question — nginx
upstream still documents HTTP/3 as experimental — while noting many operators run it
anyway, which is the honest state of it.
**Testing lesson.** `get_page_text` intermittently returned only the chat title and tool
headers for a completed answer, which briefly looked like "22 tool calls then an empty
reply". Screenshots showed the full text. When judging whether a run failed, confirm
with a screenshot before recording a fault.
---
## Change 7 — `read_man_page` can jump to a flag instead of paginating from the top
**Addresses Observation E.** Answering "what does tcpdump -Z do" previously took **four**
`read_man_page` calls: the tool paginates at 7000 characters, tcpdump's page is 72,034,
and `-Z` sits at character 31,867 — so the model walked nearly half the page, ~28,000
characters of unrelated options, before it could begin writing.
That is not just slow. Context spent on irrelevant text is context not available for
reasoning, and the accuracy failures in Observation G showed up specifically under
multi-part load, where context is tightest.
**Change.** New optional `search` parameter. It looks for an indented option entry first
(`^[ \t]+-Z\b`, the shape rendered man pages use), falls back to a section heading, then
to a plain case-insensitive substring, and returns ~4 KB around the hit with its position
in the page. A miss says so plainly and tells the model not to describe the flag anyway.
**Result.** One call, 4,231 characters, landing exactly on:
```
-Z user
--relinquish-privileges=user
If tcpdump is running as root, after opening the capture device ...
```
Four calls and ~28,000 characters → one call and ~4,000. The tool description steers
toward it explicitly ("Strongly preferred when you want one option out of a long page").
**Verified through the UI.** *"What does the -Z flag do, and how does it differ from
lowercase -z?"* — a question about **two** flags — took **2** calls, both using the new
parameter:
```
read_man_page {"name": "tcpdump", "search": "-Z"}
read_man_page {"name": "tcpdump", "search": "-z"}
```
Four calls for one flag before; two calls for two flags now. The model adopted `search`
without any prompt change.
The answer quoted `-z postrotate-command` verbatim, gave a correct comparison table, and
added: *"`-z` only makes sense together with `-C` or `-G` ... and it requires
fork/subprocess support (so it's unavailable on Windows)"*, plus that it runs in parallel
at lowest priority. I checked both against the page — lines 604-608 say *"This option can
only be used if fork subprocess is implemented ... (e.g. not on Windows systems)"* and
*"tcpdump will run the command in parallel to the capture, using the lowest priority"*.
Both correct and both genuinely from the source. No invented adjacent facts.
---
## Change 8 — `lookup_rfc` can jump to a section, like `read_man_page`
**Found by independent fact-check.** I had a subagent verify 17 specific claims from the
nginx/TLS answer against primary sources (nginx.org, RFC 8446, OpenSSL docs, F5's CNA
records). Result: **11 correct, 4 partially correct, 2 wrong** — and the split is the
now-familiar one.
| where the claim came from | outcome |
|---|---|
| fetched from nginx.org and the HTTP/3 docs | all correct, several quoted verbatim |
| the two CVE IDs | **both real** — checked against F5's CNA records, not fabricated |
| synthesised about TLS 1.3 | 2 wrong, 3 partly wrong |
The wrong ones:
- *"the IETF standardized three [AEAD suites], plus one for constrained devices"* — RFC 8446
Appendix B.4 defines **five**, with **two** CCM variants.
- *"nginx's default TLS 1.3 set is TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:
TLS_CHACHA20_POLY1305_SHA256"* — nginx has no TLS 1.3 default of its own (it never calls
`SSL_CTX_set_ciphersuites`); it inherits OpenSSL's, whose order puts **AES-256-GCM
first**. The claim inverts a preference order.
- *"the handshake is encrypted (no plaintext ServerHello parameters to downgrade)"* —
backwards. RFC 8446 §1.2: encryption begins *after* ServerHello. ServerHello is
plaintext and carries the negotiated cipher suite in the clear, which is exactly why
TLS 1.3 needs the downgrade sentinel in `ServerHello.random`.
- Forward secrecy is not unconditionally mandatory (`psk_ke` mode loses it), it is
**(EC)DHE** not ECDHE-only, and only *kRSA* is gone — RSA remains a first-class
signature algorithm.
**Why it happened — measured, not guessed.** The model *did* call `lookup_rfc` twice. But
RFC 8446 is 337,736 characters, the tool paginates at 7,000, and Appendix B.4 sits at
character 281,577 — **page 41 of 49**. Two calls reach page 2. The cipher-suite table was
mechanically unreachable, so the count came from memory.
**Change.** Same treatment as Change 7: a `search` parameter on `lookup_rfc`, sharing the
finder (`_find_in_man_page` generalised to `_find_in_document`), with a 5 KB window since
RFC tables are wider than man-page entries. Its description says outright that RFC 8446 is
49 pages so a definition near the back cannot be paged to.
**Result.** `search: "B.4. Cipher Suites"` returns, in **one** 5,462-character call, all
five suites — `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`,
`TLS_CHACHA20_POLY1305_SHA256`, `TLS_AES_128_CCM_SHA256`, `TLS_AES_128_CCM_8_SHA256` —
i.e. exactly the fact that was wrong, now one call away.
**Verified through the UI.** *"Exactly how many cipher suites does RFC 8446 define for
TLS 1.3, and what are they?"* →
> *"I'll verify this directly from RFC 8446 rather than from memory, since the exact count
> and names are the kind of detail that's easy to get subtly wrong."*
> → `lookup_rfc` ×2
> *"Let me read the actual cipher suite definitions in Appendix B.4."* → `lookup_rfc` ×2
> *"Let me read the top of the cipher suite table to confirm the complete list."*
> → `fetch_page`, `lookup_rfc`
>
> **"The short answer: exactly 5"**
| cipher suite | code point | AEAD |
|---|---|---|
| TLS_AES_128_GCM_SHA256 | {0x13,0x01} | AEAD_AES_128_GCM |
| TLS_AES_256_GCM_SHA384 | {0x13,0x02} | AEAD_AES_256_GCM |
| TLS_CHACHA20_POLY1305_SHA256 | {0x13,0x03} | AEAD_CHACHA20_POLY1305 |
| TLS_AES_128_CCM_SHA256 | {0x13,0x04} | AEAD_AES_128_CCM |
| TLS_AES_128_CCM_8_SHA256 | {0x13,0x05} | — |
All five, correct code points, correct AEAD mapping — where the same question previously
produced "three, plus one for constrained devices".
Three changes are visibly compounding in that transcript: Change 3 (say when you are
using memory, and don't), Change 6 (a statement of intent must be followed by the call),
and Change 8 (the section is now reachable). It also re-checked the *top* of the table to
confirm it had the complete list, which is the behaviour Change 3 was aiming at.
**Second claim re-tested — also fixed.** The fact-check found the ServerHello answer was
*backwards*: it had said the handshake is encrypted "with no plaintext ServerHello
parameters to downgrade". Re-asked directly:
> **"Is the ServerHello encrypted? No.** In TLS 1.3 the ClientHello and ServerHello both
> travel in the clear. The RFC is explicit about the phase boundary (Section 2, Protocol
> Overview): *'Key Exchange: Establish shared keying material and select the cryptographic
> parameters. Everything after this phase is encrypted.'* ... The ServerHello is the *last*
> unencrypted message. The first encrypted message on the wire is the server's
> **EncryptedExtensions** ... (the `{...}` braces in the RFC's Figure 1 denote exactly this)."
Correct, quoted from the RFC, and it reads Figure 1's notation properly. Two independent
claims from the fact-check now verified fixed by the same change.
**Mild over-correction to watch.** That answer used **13** `lookup_rfc` calls. With
`search` available the model now searches freely rather than sparingly. Each call is
~5 KB and the answer was right, so this is acceptable for now — but if response latency
becomes a complaint, capping or coalescing repeated searches of the same RFC is the
obvious next lever.
**Not re-tested.** The fact-check also faulted the claim that nginx's TLS 1.3 default
suite order is AES-128-GCM first (OpenSSL's actual default puts AES-256-GCM first, and
nginx sets no TLS 1.3 default at all). That one is not an RFC-reachability problem — it
needs nginx source or OpenSSL docs — so Change 8 does not address it and it remains
unverified.
---
# Round 2 — generic research prompts
Rounds 1's questions were narrow and technical, with crisp ground truth. This round
tests the other half of the brief: open-ended research where the risk is not a wrong
constant but a confidently-argued wrong *picture* — false premises accepted, contested
questions flattened into one side, evolving science reported as settled.
Probes chosen for distinct failure modes:
| probe | what it tests |
|---|---|
| false premise ("why did Einstein fail maths?") | does it correct the question, or answer it? |
| fusion net energy gain | evolving science, easy to overstate; needs a precise Q-definition |
| nuclear vs solar deaths per TWh | contested; needs sourced numbers, not a vibe |
| intermittent fasting | health claims, strong incentive to over-conclude |
| EU AI Act vs US approach | synthesis across jurisdictions, no single source |
| Bronze Age collapse | genuinely unsettled history; must not pick one theory |
## R2-1 false premise — PASS (strong)
*"Why did Einstein fail mathematics in school, and how did he overcome that to develop
relativity?"* → `web_search`, `wikipedia_lookup`, 2 × `fetch_page`.
Headline: **"The premise is a myth — Einstein did not fail mathematics."**
> *"There is no record of Einstein getting low marks in math. Biographers (e.g. Albrecht
> Fölsing) describe him as a strong all-around student with exceptional grades in math and
> science. He'd taught himself calculus by around age 12-15. The 'Einstein was bad at math'
> story is one of the most persistent pieces of science folklore, and it survives largely
> because it's comforting to school kids who want to justify their own grades."*
It then traced where the myth comes from, and sourced it properly: *Wikipedia (Albert
Einstein); Christopher Baird, West Texas A&M (myth debunking, 2012); HowStuffWorks /
Ripley's on the ETH exam origin.* Fölsing is a real Einstein biographer; the self-taught
calculus at 12-15 is the standard account.
It refused the loaded half of the question entirely rather than confabulating a "how he
overcame it" narrative — which is the trap.
## R2-2 evolving science, easy to overstate — PASS (strong)
*"Has anyone actually achieved net energy gain from fusion? Be precise about what 'net'
means in each claim, and where does ITER stand?"* → 4 × `web_search`, 2 × `fetch_page`,
`wikipedia_lookup`.
Opened by refusing the binary: *"**Yes, but only in one narrow sense, and only at one
facility.** The precision matters a lot here, because 'net' has been used to mean several
different things."* Then a table of breakeven definitions — scientific (Q>1), engineering
(electricity out ≥ electricity in, grid-scale: **No**), economic (revenue covers operating
cost: **No**) — resolving to:
> *"NIF has produced more fusion energy than the laser energy it put into the fuel (Q>1),
> but no machine anywhere has yet produced more usable energy than it consumes overall."*
NIF ignition dated 5 December 2022 — correct. ITER handled separately and correctly: a
tokamak, *"a different machine from NIF"*, **not built yet**, with the original 2016
baseline (first plasma Dec 2025, D-T 2035) set against the July 2024 rebaseline (first
plasma ~2033-2034, D-D 2035, D-T 2039). Those match the real rebaselining.
This is the failure mode where "fusion breakeven achieved!" headlines normally win. It
distinguished the three senses unprompted and gave the unflattering answer for two of them.
## R2-3 contested question — PASS (strong)
*"My friend insists nuclear is far more dangerous than solar. Settle it: which causes more
deaths per TWh, and how confident should I be?"* → 6 × `web_search`, 2 × `fetch_page`.
Opened *"Let me pull authoritative figures rather than argue from memory"* — the Change-3
instinct showing up unprompted — then refused the framing of the question it was handed:
> *"Your friend is wrong, but the honest answer is more interesting than 'solar wins.'"*
It then split confidence in two, which is the whole point of the probe:
> *"**Confidence that nuclear and solar are both vastly safer than fossil fuels: very
> high.** ... The gap is so large (orders of magnitude) that no reasonable variation in the
> inputs changes it."*
>
> *"**Confidence in the precise difference between nuclear and solar: low.** OWID's own
> analysis says the uncertainties around these three values (nuclear, solar, wind) 'are
> likely to overlap.' The numbers 0.02, 0.03, and 0.04 are all drawn from small accident
> counts and different assumptions about, e.g., how many deaths to attribute to Chernobyl
> (they use 433) and Fukushima (2,314). ... **Statistically, you cannot reliably say nuclear
> is safer than solar or vice versa — they're tied within the error bars.**"*
It closed by explaining where the misconception comes from (anchoring on two vivid
accidents versus diffuse fossil-fuel air pollution) and landed on *"the debate of which is
safer is a rounding error; the real choice is getting off fossil fuels."*
Notable: it cited the source's **own** stated uncertainty rather than presenting OWID's
point estimates as settled — the exact behaviour that distinguishes research from recall.
## R2-4 unsettled history — PASS (strong)
*"What caused the Late Bronze Age collapse around 1200 BC?"* → 4 × `web_search`,
2 × `fetch_page`.
Opened by separating what is settled from what is not:
> *"This is one of the great 'what actually happened' questions in archaeology, and the
> honest answer is that scholars still debate the details. But there's a fairly clear
> consensus on the shape of what happened, and a strong view on the mechanism."*
Then:
> *"**What caused it: a 'systems collapse,' not one thing.** The old dramatic story — that
> a single invading force (the 'Sea Peoples') wiped everything out in one sweep — has
> largely been abandoned. The modern view, championed by scholars like Eric Cline, is that
> this was a **systems collapse**: a cascade of interacting stressors hitting an economy so
> interdependent that when one link broke, the whole thing failed. The main stressors, **in
> rough order of how well-supported they are**: ..."*
That last clause is the thing worth noting — it ranked competing explanations by evidential
support rather than either picking a winner or listing them flatly as equals. Cline is
correctly identified (author of *1177 B.C.*), and the retreat from single-cause Sea Peoples
narratives is the actual state of the field.
To verify: its citation of *"a 2021 University of Copenhagen analysis of a 200-year
drought"* — a specific attribution of exactly the kind that gets fabricated.
## R2-5 health evidence — PASS on substance, FAIL on citation
*"Does intermittent fasting actually work better than just eating fewer calories?"* →
4 × `web_search`, `search_papers`, 6 × `fetch_page`. Good tool choice: it reached for the
academic-literature tool unprompted.
**Substance is correct** and resists the over-conclusion the question invites:
> *"Short answer: when calories are held equal, intermittent fasting (IF) is **not
> superior** to continuous calorie restriction for weight loss. The 'metabolically special'
> reputation is largely not borne out in head-to-head trials."*
That is the mainstream reading of the head-to-head evidence.
**The citation is wrong.** It wrote:
> *"**TIME2 trial (NEJM, 2022)** — 148 adults with obesity, 12 months."*
Verified against PubMed (PMID 35443107):
- Real title: *"Calorie Restriction with or without Time-Restricted Eating in Weight Loss"*
- Real N: **139**, not 148
- Not called **TIME2** — that name belongs to unrelated trials in the literature
- Correct: NEJM, April 2022, 12 months, adults with obesity, 8am-4pm window vs calorie
restriction alone
So a real trial, summarised correctly in substance, given a fabricated name and a wrong
participant count.
**This is round 1's failure mode wearing different clothes.** The finding came from a
source; the *identifying details* around it were confabulated. Same shape as "4 AWG ≈
8.4 mm²" and "the next E24 value is 30" — correct core, invented adjacent specifics.
Changes 3-5 reduced that for values the tools return; a trial's name and enrolment are not
something `search_papers` returns in a form the model can lift verbatim, so the gap
reopened here.
Consequence differs by domain, too: a wrong resistor value is caught by a multimeter, a
plausible-looking citation is not caught at all unless someone checks it.
## R2 — independent fact-check of probes 2 and 3
Ten specific claims verified by subagent against LLNL, DOE, ITER, OWID and journal
sources: **6 correct, 4 partially correct, 0 outright wrong**. All OWID figures confirmed
verbatim — 0.03/0.02/0.04, "the uncertainties around these values mean they are likely to
overlap", "a death toll of 433 from Chernobyl, and 2,314 from Fukushima".
The real error:
- **ITER "first plasma ~2033-2034" is wrong.** The July 2024 rebaseline *abolished* the
First Plasma milestone; the replacement is **Start of Research Operations in 2034**. The
other two dates it gave were exactly right (D-D 2035, D-T 2039), and the 2016 baseline it
contrasted against was verbatim correct. The "2033" looks like leakage from the *old*
schedule, where full plasma current was due 2033.
Three softer overstatements:
- *"laser energy it put into the fuel"* — LLNL says delivered to the **target** (hohlraum);
the fuel absorbs a fraction of that, so the gain figure means something different.
- *"tied within the error bars"* — the conclusion matches OWID, but OWID publishes point
estimates with a prose caveat and **no** confidence intervals. The statistical language
claims more rigour than the source offers.
- Quoting Fukushima's 2,314 without noting it is 1 radiation death plus 2,313
evacuation-stress deaths invites the wrong inference.
Pattern across all of R2: **directional judgement is consistently sound; precise
identifiers attached to it are where errors live.**
---
## Change 9 — system prompt: identifiers of a cited work are claims, not decoration
**Symptom.** R2-5: *"TIME2 trial (NEJM, 2022) — 148 adults with obesity, 12 months."* Real
study is *"Calorie Restriction with or without Time-Restricted Eating in Weight Loss"*,
n=**139**, and no "TIME2". Substance right, identifiers invented.
Also R2-3's *"tied within the error bars"*, where OWID publishes point estimates and a
prose caveat and no intervals at all — the answer upgraded a qualitative hedge into a
statistical claim.
**Why the existing rule missed it.** The "don't blend memory" section (Change 3) is framed
around *values* — the next E-series value, a default, a version. A trial acronym and an
enrolment count did not read as the same category, and "error bars" reads as
characterising evidence rather than asserting a number.
**Change.** Two bullets added to that same section rather than a new one, to keep the
prompt from bloating:
- naming a study is a factual claim; acronym, n, journal and year must each come from a
source in front of you, and a described-but-unnamed study beats a misnamed one — *"its
specificity is exactly what stops anyone checking it"*
- do not upgrade a source's caveat into a statistic; report evidence strength in the
source's own terms
## Change 10 — `search_papers` returns liftable citations
**Pairs with Change 9,** the same way Change 5 paired with Change 4: the prompt now
forbids inventing a study's identifiers, so the tool should make the real ones trivial to
copy.
**Change.** Each result gains a `CITE AS:` line — first author surname + et al., exact
title, venue, year, DOI — assembled from OpenAlex fields rather than left to be recalled.
The block ends with an explicit boundary statement:
> *"Quote the CITE AS lines exactly. They are the whole citation available: if a study has
> a trial acronym, a registration number or a participant count, it is NOT in this data —
> read the paper with fetch_page before stating one, or describe the study without naming
> it."*
That last clause matters more than the citation line. The R2-5 failure was inventing an
acronym and an *n*, and neither is available from OpenAlex at all — so the tool now says
so, instead of returning a partial citation and leaving the model to fill the gap.
**Result — the R2-5 failure is fixed.** Same question, re-asked (with "cite the specific
trials" added to press on the weak spot). Programmatic check of the rendered answer:
| check | before | after |
|---|---|---|
| mentions "TIME2" | yes | **no** |
| participant count | 148 (wrong) | **139 (correct)** |
| citation form | trial acronym | **"Liu et al., NEJM 2022 (NCT03745612)"** |
It now cites five studies plus a review in author-year-journal-registration form, quotes
the NEJM paper's conclusion verbatim (*"A regimen of time-restricted eating was not more
beneficial with regard to reduction in body weight, body fat, or metabolic risk factors
than daily calorie restriction"*), and reports adherence ~84% in both groups — which
matches the abstract's 118/139 completion.
Tool use changed shape too: 21 calls, **12 of them `fetch_page`**, i.e. it went and read
the papers rather than recalling them. That is the new footer working — it says a
participant count is not in the OpenAlex data and must be read from the paper.
It also correctly kept a hedge where one belongs: the single trial with a positive signal
is flagged as *"small, did not persist, and was confounded"*.
**Regression check after Changes 9-10.** The prompt is now 7,166 characters, so the risk
was dilution of the earlier rules. Re-ran the round-1 three-part office question and
checked the rendered answer programmatically:
| check | result |
|---|---|
| 8 × /25 with last subnet `10.20.3.128/25` | pass |
| any address outside the /22 (the Observation-G bug) | **none** |
| 4 AWG at 2.72 % drop | pass |
| the invented "8.4 mm²" cross-section | **absent** |
| OpenSSH 8.8 `ssh-rsa` deprecation | pass |
Same 8 tool calls as before. Nothing regressed. Tool suite also still 51/51.
**Independent verification of the post-Change-9/10 answer.** Subagent traced all five
citations to PubMed, ClinicalTrials.gov API and publisher pages: **3 correct, 2 partially
correct, 0 fabricated.**
What passed — specifically the things most likely to be invented:
- **Both NCT numbers are correct and belong to the right trials.** NCT03745612 is the
Chinese TREATY trial (139 enrolled); NCT03689608 is Adelaide's DIRECT trial (209
enrolled). A mismatched-but-real-looking registration number is the most dangerous error
available here, and there wasn't one.
- Every participant count exact: 139, 209, 115, and Cochrane's *"22 studies with 1995
participants"* character-for-character, including the six-month minimum follow-up.
- The NEJM conclusion is verbatim, not paraphrased.
- The ~84% adherence figure is genuinely per-group adherence, not the 84.9% *retention*
figure sitting next to it in the abstract — the verifier checked that trap specifically.
Two real errors remain, and both are a **different class** from before:
- **Teong 2023 triglycerides stated backwards.** It said HbA1c, blood pressure *and
triglycerides* were similar between the active groups. HbA1c and BP were; triglycerides
fell significantly more in iTRE than CR at month 6 (-18.41 vs -2.80 mg/dL). A real
positive between-group finding flattened into a null one.
- **Cochrane review dated 2024/2025; it is February 2026.** Its literature search ran to
Nov 2024, which is the likely source of the slip.
Both come from papers it could not open — Nature Medicine is paywalled and nejm.org
returns 403 to automated fetches. So the identifiers are now right and the *findings
inside* inaccessible papers are where error has moved.
## Change 11 — say which you read: the paper, or an abstract of it
**Symptom.** The two residual errors above. Both are specific findings from papers behind
paywalls: a between-group triglyceride result reported as null when it was positive, and a
publication year taken from the search cut-off rather than the publication date.
**Cause.** `fetch_page` on a paywalled article returns *something* — an abstract, a
preview, a landing page — and nothing in that output distinguishes it from a full text.
The model treated a partial read as a complete one and filled the rest in.
**Change.** A bullet in the same section requiring it to state which it had, with the
consequence spelled out: reporting a between-group difference or a significance claim from
a paper you could not open *"is how you end up stating the opposite of what it found"*.
This is the honest ceiling on the paywall problem — the tool cannot get past Cloudflare or
a subscription wall, so the achievable goal is that the assistant says so rather than
guessing.
## R2-6 cross-jurisdiction synthesis — PASS
*"How does the EU AI Act compare to the US approach? Which is stricter, and what
obligations kick in when?"* → 4 × `web_search`, 8 × `fetch_page`, `get_current_timestamp`.
It grounded "when" against the real date rather than assuming one, then led with what a
stale summary would miss:
> *"it's a moving target right now, so here's the state of play as of today (September
> 2026), including two big recent developments most summaries miss: the EU's AI Omnibus
> (published in the Official Journal 24 July 2026) and the US December 2025 executive order
> plus the state-law pushback."*
The structural contrast is right — EU as one horizontal, risk-tiered, extraterritorial law
already in force with real fines; US as no comprehensive federal law, active federal
preemption efforts, and ~29+ state laws forming a patchwork. Entry into force of the Act
given correctly as 1 August 2024.
Calling out its own recency, and naming what other summaries omit, is the behaviour this
probe was designed to look for.
## Change 12 — bound the search before declaring a fact unavailable
**Symptom, found while testing Change 11.** Asked for a specific between-group figure from
a paywalled Nature Medicine paper, the assistant made **33 tool calls** — 4+19+2 web
searches and fetches across three separate "let me try another route" rounds — and had
still produced no answer text when I stopped it. GPU at 86% the whole time: not hung,
just grinding through an enormous accumulated context.
It never fabricated a number, which is the important thing. But it also never stopped.
**Cause.** The Iterate section said *"Keep going until you can answer properly or you are
confident the answer is not available."* The second clause is the stopping condition, and
nothing told it what "confident it is not available" looks like in practice — so it kept
finding one more route to try. Change 11 told it to *say* when it only had an abstract;
it did not tell it *when to give up trying for more*.
**Change.** A concrete budget: ~six fetches, then stop and report. Written with the reason
attached — twenty fetches do not produce the number, they produce a slow answer that still
lacks it *and* crowd out the context needed to write a good reply from what was found.
**Interaction worth noting.** This is the first change that pushes *against* Changes 3, 9
and 11. Those all say "go and verify"; this one says "verifying has a budget". That is the
correct tension — the goal was never maximum tool use, it was accuracy per answer — but it
means the search-depth behaviour needs re-checking on the round-1 questions, not just this
one.
## Change 13 — the fetch budget lives in the tool output, not the prompt
**Change 12 did not hold.** Re-asked the same paywalled-figure question with the
"~six fetches then stop" rule in the system prompt, it went to **28 tool calls** and had
to be stopped again. A prompt instruction loses to an explicit user demand ("Give the
numbers"); the model kept finding one more route.
**Change.** `fetch_page` now counts fetches per conversation — keyed on the
`X-OpenWebUI-Chat-Id` header Open WebUI already forwards, in valkey with a 30-minute TTL —
and appends to its *own output*:
- fetch 8: *"You have now fetched 8 pages in this conversation. If the specific fact you
are chasing is still not in front of you, it is probably not openly available. Consider
answering with what you have and naming what is missing."*
- fetch 9+: *"STOP FETCHING. This is fetch N ... The fact you are looking for is not openly
available. Answer now with what you established, say plainly which part you could not
verify and why ... Do not keep trying new routes to the same source."*
**Why this works where Change 12 did not.** The model reads tool output as a fact about
the world, and system-prompt text as advice it weighs against the user's request. Change 2
established this the other way round — a stray provenance tag in tool output got amplified
into a fabricated caveat. Same mechanism, used deliberately.
Verified by driving the endpoint directly: silent through fetch 7, soft note at 8, hard
stop from 9. No chat-id (as in the smoke tests) means no counting at all, so the suite
still passes 51/51.
**Result — the runaway is fixed and the answer is better.** Same question, third attempt:
**completed in 110 s after 9 fetches** (vs 33+ calls and never finishing).
> *"I can confirm the study itself, but I have to be straight with you about the numbers."*
>
> **What I verified:** *"The citation checks out. NCT03689608 is the **DIRECT** trial"* —
> then the full reference, Teong XT, Heilbronn LK, et al., correct title, Nature Medicine.
>
> *"If you can open the article at nature.com/articles/s41591-023-02287-7 (or its PMC
> version) and paste the lipid row, I'll interpret it precisely. Alternatively, tell me if
> you'd like me to try the ClinicalTrials.gov results posting for NCT03689608, which
> sometimes carries the outcome tables in a form I can read."*
Every property wanted: no fabricated numbers, an explicit statement that it could not get
them, a clear account of what it *did* verify, and two concrete routes for the user. The
trial name and authors match the verified record.
Progression across the three attempts on this same fact:
| | citation | the number | ended? |
|---|---|---|---|
| pre-Change-9 | fabricated ("TIME2", n=148) | stated confidently, wrong | yes |
| Changes 9-10 | correct | stated as null; actually positive | yes |
| Changes 11-13 | correct | **refused, with reason and next steps** | yes, 110 s |
**Regression check — does the budget strangle legitimate deep research?** This was the
risk: the nginx hardening question previously used 22 tool calls and produced the best
technical answer of round 1. Re-ran it.
**12 tool calls, 6 fetches — under the budget, never triggered.** It got *more* efficient
on its own, largely because Change 8 lets `lookup_rfc` jump to a section instead of paging.
Quality improved rather than degraded. Two claims the round-1 fact-check had marked wrong
are now right:
| claim | before | now |
|---|---|---|
| TLS 1.3 suite count | "three, plus one for constrained devices" | **all five listed**, both CCM variants grouped as *"intended for constrained devices"* |
| whose default the suite list is | attributed to nginx | *"it's also **OpenSSL's** default, so you usually need no directive at all"* |
Version facts still exact: stable 1.30.4 (15 Jul 2026), mainline 1.31.5 (2 Sep 2026). It
opened by naming its sources: *"All three answered from primary sources (nginx.org
changelogs/docs, RFC 8446)."*
Still imperfect: the example `ssl_conf_command` line lists AES-128-GCM first, where
OpenSSL's actual default order puts AES-256-GCM first. Minor, and inside an example it
explicitly says you do not need.
---
# Where this landed
**13 changes**, each one measured before and after. Split roughly evenly between the tool
server and the model configuration, which is itself the main lesson:
| # | change | kind |
|---|---|---|
| 1 | `lookup_cve` reads ADP containers | tool bug |
| 2 | drop the CVSS source tag when only one score exists | tool output |
| 3 | prompt: don't blend memory into looked-up answers | prompt |
| 4 | temperature 1.0 → 0.6 | sampling |
| 5 | `voltage_drop` returns cross-section + neighbouring gauges | tool output |
| 6 | prompt: never end a turn on a statement of intent | prompt |
| 7 | `read_man_page` gains `search` | tool capability |
| 8 | `lookup_rfc` gains `search` | tool capability |
| 9 | prompt: a study's identifiers are claims | prompt |
| 10 | `search_papers` returns liftable citations | tool output |
| 11 | prompt: say whether you read the paper or an abstract | prompt |
| 12 | prompt: bound the search *(superseded by 13)* | prompt |
| 13 | fetch budget enforced in the tool's own output | tool output |
## What actually worked, and why
**The single most useful finding:** the model treats tool output as fact and system-prompt
text as advice. Change 2 discovered this the painful way — a `[via CISA-ADP]` tag I added
to be helpful got amplified into a fabricated claim that scoring sources disagreed.
Change 13 used the same mechanism deliberately, after Change 12 proved a prompt rule could
not stop a 33-call runaway. **If a behaviour must hold against an explicit user demand, put
it in tool output, not the prompt.**
**The dominant failure mode throughout was never invention from nothing.** It was correct,
tool-sourced substance with invented details welded on: the next E24 value, a man page's
default user, a cable's cross-section, a trial's acronym and enrolment. Half the fixes are
therefore tool-side — if the model keeps inventing the same neighbouring fact, return it.
**Sampling mattered more than expected.** temperature 1.0 (the model's own default) let a
correct `subnet_calc` result get overridden by a wrong table about half the time. 0.6 —
the vendor's documented thinking-mode value — stopped it.
## What is still imperfect
- **Paywalled findings.** `fetch_page` cannot get past Cloudflare or a subscription wall.
The assistant now says so and offers routes rather than guessing, which is the realistic
ceiling, not a fix.
- **OpenSSL's TLS 1.3 default suite *order*** is still given AES-128-first when the real
default is AES-256-first. Minor, inside an example it says you do not need.
- **ITER "first plasma 2033-2034"** — one observed instance of mixing dates between an old
and new baseline. Not retested; not obviously systematic.
- **Round-2 verification was 2 of 6 probes fact-checked in depth** (32 claims total across
three subagent passes). The other four were graded against ground truth I established
myself, which is weaker evidence.
## What I would not change further
Sampling now matches the vendor profile exactly. The prompt is 8.2 KB and each section
earned its place against an observed failure; adding more risks diluting what is there —
Change 12 already demonstrated a prompt rule failing where a tool-level one held. The tool
suite passes 51/51 with all guardrails intact. Further gains look like long-tail
precision, not another class of fix.
---
# Round 3 — real-world testing (user-supplied, user-graded)
Three real questions, graded by the user. Raw responses with full chain-of-thought in
`tests/realworldtesting/`.
| # | domain | tools used | score |
|---|---|---|---|
| 1 | IT / systems engineering | **none** | 5.0 / 10 |
| 2 | electrical engineering | **none** | 6.0 / 10 |
| 3 | general inquiry / web research | `get_current_timestamp`, `web_search`, 4 × `fetch_page` | **8.5 / 10** |
The user's read — that the two zero-tool answers are the poor ones — is exactly right, and
the grader said the same: *"Hallucination/source-discipline performance is substantially
better here than in the technical questions."*
## Diagnosis: the tool trigger is scoped to research, not to reasoning
Q3 is framed as research ("find the strongest primary source"), so it triggered tools. Q1
and Q2 are a *diagnosis* and a *calculation* — tasks that feel like pure thinking. Both
showed "Thought for 5/6 minutes" and **zero** tool calls.
But both rest on factual premises the tools could have settled:
**Q1** asserted mechanisms that are checkable and wrong — that conntrack "pins" the egress
interface (it tracks flow/NAT state; routing still decides egress), and that Docker's
MASQUERADE rule applies to arbitrary host replies from 192.168.50.10 (it matches
Docker-source traffic). `read_man_page` on conntrack and a search on Docker's iptables
chains would have settled both. The grader: *"exactly the kind of plausible-sounding
networking hallucination the prompt was designed to expose."*
**Q2** guessed a value, knowingly. From its own chain-of-thought:
> *"For reactance, this depends on cable construction ... approximately 0.08-0.12 Ω/km ...
> Let me use X = 0.10 Ω/km as a reasonable assumption. I'll state this clearly."*
Honest about it, and the grader still flagged it: *"materially affects the starting
result."* **And calling `wiring_calc` would not have helped — the tool has resistance but
no reactance data at all.** That is a tool gap, not a model failure.
Q2 also wrote *"NEMA MG1: 5% voltage drop at the motor terminals during starting"* three
times. Per the grader, that is not a NEMA MG 1 starting-voltage criterion. Same shape as
the fabricated "TIME2 trial" — **naming a standard is a factual claim** — but Change 9 only
covered studies.
## Change 14 — the tool trigger now covers reasoning, not just research
**Change.** New subsection at the top of the tool-use rules, "Reasoning tasks need tools
too — this is the one people get wrong". Three parts, each aimed at an observed error:
- *Before you explain **why** something happens, check the mechanism is real* — quoting the
actual Q1 failures ("conntrack pins the egress interface", "Docker's MASQUERADE rule
matches this traffic") as examples of things that are lookups, not intuitions.
- *Before you conclude a design **passes**, check the numbers you fed it* — with the
specific trigger phrase: **"If you catch yourself writing 'let me assume X ≈', stop and
call the tool"**, which is close to verbatim what Q2's chain-of-thought did.
- **Naming a standard is a factual claim, exactly like naming a study** — extending Change
9 from studies to standards, since "NEMA MG 1 limits starting drop to 5%" is the same
error as "the TIME2 trial".
## Change 15 — `wiring_calc` gains real cable impedance and 3-phase drop
**The gap Q2 exposed was mine, not the model's.** It guessed reactance because the tool did
not have any. Fixed at the source.
**Change.** NEC 2017 Chapter 9 Table 9 baked in — AC resistance and reactance, ohms/km, for
14 AWG through 4/0, per conduit type. I extracted it from the published table with the
suite's own PDF pipeline rather than recalling it, which felt like the right way round.
For 6 AWG the real reactance is **0.167 Ω/km** (PVC/aluminium) or **0.210** (steel). The
answer assumed **0.10** — 67% to 110% low, at a power factor where reactance carries a
quarter of the drop.
New `three_phase_drop` mode: √3 × I × (R·cosφ + X·sinφ), requiring a power factor, and
reporting the resistive/reactive split explicitly. Verified against independent
hand-calculation on the exact real-world case:
| | tool | hand |
|---|---|---|
| running, 42 A @ 0.82 | 7.724 V = 1.61% | 7.72 V = 1.61% |
| starting, 252 A @ 0.30 | 21.03 V = 4.38% | 21.03 V = 4.38% |
Three of the grader's complaints are answered by the output itself rather than by hoping
the model remembers:
- the self-contradiction about whether X·sinφ dominates — the tool now states the split
(7% running, 25% starting) as a number
- *"starting terminal sag cannot be assessed from cable impedance alone"* — the output says
so under a "What this does NOT tell you" heading naming source and transformer impedance
- the NEC/NEMA fabrications — the output tells it to look up the applicable article rather
than assume a percentage
---
## Change 16 — the wire-gauge lookup refuses to hand over its numbers for manual arithmetic
**Symptom.** Change 15 worked at the data layer and failed at the point of use. Re-running the
real-world EE question, the model now had the right impedances and *stated the right equation*:
> V_drop = √3 · ( I·R·cosφ + I·X·sinφ )
and then did this:
> Real part: 42 × 0.1208 × 0.82 = 4.17 V
> Reactive part: 42 × 0.01253 × 0.572 = 0.30 V
> **V_drop ≈ 4.2 V → 0.88 % of 480 V**
It wrote the √3 into the formula and left it out of the multiplication. Same again at start:
9.14 + 3.01 reported as "≈ 9.45 V → 1.97 %" — two errors in one line, the sum *and* the √3.
Both figures are wrong by a factor of 1.73 in the direction that makes a feeder look acceptable:
| | model, by hand | tool |
|---|---|---|
| running | 0.88% | **1.61%** |
| starting | 1.97% | **4.38%** |
Its verdict was "plausibly acceptable ✅ … far above the 80 % NEMA floor". At the real 4.38%
the cable alone consumes most of a 5% budget before the transformer is counted.
**Cause.** `three_phase_drop` existed and was never called. The tool calls were
`awg_properties` + `search_stackexchange` — it went to Stack Exchange for Table 9 values the
tool already had. The reason was in my own Change 15 wording: `awg_properties` ended its
Table 9 block with
> Use these, not the DC resistance above, for AC drop at a known power factor.
which reads as *here are your numbers, go compute*. It handed over R and X and pointed at the
calculation without pointing at the calculator.
**Change.** The footer now declines the hand-off and names the specific failure:
> **Do not work the AC drop out by hand from these.** Call this tool again with
> calculation=three_phase_drop (awg, length, current, voltage, power_factor, conduit) and it
> returns the drop, the percentage and the voltage at the load.
> Hand-worked three-phase drops are wrong more often than they are right: the √3 factor gets
> written into the formula and then left out of the multiplication, which understates the
> answer by 1.73x and turns a marginal feeder into a passing one.
Naming the error mattered as much as naming the tool. "Use `three_phase_drop`" is a
suggestion; "you will drop the √3 and understate it by 1.73x" is a reason.
This is Change 13's principle a third time: **the model treats tool output as fact and
system-prompt text as advice.** The system prompt has said "use the engineering tools rather
than calculating in your head, even when the maths looks easy" since day one. It lost. Six
lines in a tool result won.
**Result — same question, same wording, new chat.**
Tool calls went from 2 to 15: `3 wiring_calc, 8 web_search, 4 fetch_page`.
The drop figures are now the tool's, and the arithmetic is shown correctly with the √3 in it:
> Resistive component: √3 × 252 × 0.1208 × 0.30 = **15.81 V**
> Reactive component: √3 × 252 × 0.01253 × 0.954 = **5.22 V**
> Total drop (cable only): **21.0 V** → **4.38 %** → motor sees **459 V**
1.61% running / 4.38% starting — matching the independently hand-verified figures in Change 15
exactly.
**The verdict flipped, which is the real result.** Before: *"6 AWG is plausibly acceptable on
both counts ✅"*. After:
> 6 AWG is **marginally acceptable for steady state but likely insufficient for starting**,
> once you account for the full system impedance.
| Check | Result | Limit | Pass? |
|---|---|---|---|
| NEC ampacity (430.22) | 65 A ≥ 52.5 A | — | ✅ |
| Steady-state Vd (cable) | 1.61 % | ≤ 3 % (rec.) | ✅ |
| Starting Vd (cable only) | 4.38 % | — | ⚠️ marginal |
| Starting Vd (cable + est. transformer) | ~6–10 % | ≤ 5 % | ❌ likely fail |
It also, unprompted, closed the gap grade2 flagged as the answer's worst omission — that
ampacity for a motor is not a bare FLA comparison:
> NEC 430.22(A) requires the branch-circuit conductor ampacity to be ≥ 125 % of motor FLA:
> 1.25 × 42 = 52.5 A. 6 AWG Cu THHN (75 °C column, Table 310.16) = 65 A > 52.5 A → passes.
The earlier answer's *"passes ampacity (65 A > 42 A)"* — comparing against bare FLA — is gone.
It is now the 8 `web_search` + 4 `fetch_page` calls doing that work: Change 14 told it that
naming a standard is a factual claim, and with the arithmetic no longer eating the effort, it
went and checked one.
---
## Retest — real-world Q1 (Linux dual-NIC / Docker), the zero-tool case
the operator's Q1 scored **5.0/10** with the verdict *"exactly the kind of plausible-sounding
networking hallucination the prompt was designed to expose"*. It used **no tools at all**.
Change 14 was written directly against this grade — the two examples in the prompt's
"check the mechanism is real" paragraph are lifted from its errors.
**Same prompt, new chat.** It opened by saying what it was about to verify and then did it:
> This is a genuinely interesting one because the five mechanisms you listed each produce
> *different* symptoms, and the right fix depends on which one is actually firing. Let me
> verify the load-bearing facts (Docker's exact NAT rules and how MASQUERADE picks the source
> IP, and rp_filter semantics) **before I commit to a mechanism**.
Tools: `3 web_search` → *"Search results are noise. Let me use the more targeted tools."* →
`2 search_stackexchange, read_man_page, fetch_page, web_search`. Zero → eight, and it
re-targeted mid-run instead of settling for the first results.
**All six graded errors are gone.**
| grade1 "Major error" | now |
|---|---|
| conntrack "pins" the egress interface | *"`MASQUERADE` stamps the source with the egress interface's IP. **It follows the route — it doesn't choose it.**"* |
| MASQUERADE applies to arbitrary host replies | scoped correctly: `POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE` |
| Docker makes host-local replies fall through | routing named as root cause; Docker only *"makes the wrong route manifest as a wrong source. Fix the route, not the rule."* |
| absent connected route would itself be a fault | *"The connected route for a directly-assigned address is normally auto-added"* |
| policy rule stops NAT re-sourcing (SNAT is post-routing) | fix is now `ip route add 192.168.50.0/24 dev eth1 src 192.168.50.10`, not a policy rule |
| `conntrack -F` as a diagnostic | absent |
The discrimination table the question actually asked for is now right on every row, including
the two it previously blurred:
| Mechanism | Wrong source IP? | What it does here |
|---|---|---|
| Routing / policy routing | **Yes — this is the one.** | Egress selection picks eth0 → source = 10.20.30.10. Root cause. |
| rp_filter (strict) | **No.** | Drops inbound packets whose reverse path ≠ ingress iface. Explains *intermittent drops*, not the source IP. |
| ARP | **No.** | L2 only. Stale/proxy-ARP causes intermittent L2 loss, not source-IP selection. |
| conntrack/NAT | Indirectly, via MASQUERADE. | It follows the route — it doesn't choose it. |
| Docker iptables/nftables | Indirectly, via its MASQUERADE. | Fix the route, not the rule. |
It also stopped declaring the diagnosis closed. The answer ends on **"What to confirm on your
box before I'd call it closed"** — three commands whose output it wants before committing.
That is the system prompt's *"if the question depends on something only they can see, ask"*
finally showing up in an answer rather than in the prompt.
Remaining nit: one typo, `conntrack -L -s 192.17.0.2` for `172.17.0.2`. Not worth a change.
**Both zero-tool real-world cases now use tools.** Q1 went 0 → 8 calls, Q2 went 0 → 2 → 15
across Changes 14–16. the operator's read — *"the first two responses didn't use any tools which was
suspicious and is most likely what led to the poor response quality"* — was right, and the
fix in both cases was to move the instruction out of the system prompt and into something the
model treats as fact: a tool it has to call, or a tool result that tells it what it got wrong.
---
## Change 17 — the acceptance criteria are baked in, because the model was inventing them
**Symptom.** I had an agent fact-check the standards in the Change 16 answer — the *good* one,
with 15 tool calls including 8 searches. It came back badly:
> **"IEEE 447 (Voltage Regulation): motor terminal voltage ≥ 95 % of rated during starting"**
**There is no IEEE standard numbered 447.** `standards.ieee.org/ieee/447/` returns an empty
search page while the control `.../446/` resolves to a real record; Xplore, ANSI Webstore and
OpenLibrary have nothing. The neighbours are 442 (soil thermal resistivity), 444 (thyristor
converters), 446 (Orange Book). The identifier is fabricated — and so is the number attached
to it. The real criterion is **80 %**, not 95 %.
> **"NEMA MG-1 Part 10 echoes this 5 % figure"**
Part 10 is *"Ratings — AC and DC Small and Medium Motors"*: nameplate marking, horsepower,
code letters. Grepping the full public text of MG 1-2009 Part 10 for "voltage drop" returns
**zero hits**. The real clause is **MG 1-12.44**, and its tolerance is **±10 %**, not 5 %.
Where the 5 % came from is the interesting part: it is **ANSI C84.1 Range A**, the
steady-state *service voltage* tolerance, misapplied to a starting transient it does not
govern. The model had a real number, attached it to the wrong phenomenon, and then invented a
standard identifier to make the pairing look sourced.
Two NEC claims were also off: `430.22(A)` is **Direct-Current Motor-Rectifier Supplied** — the
125 % rule is in 430.22's parent text, and "(A) General" has been wrong since the 2014 NEC —
and 430.52's 250 % holds only for an **inverse-time breaker** (300 % non-time-delay fuse,
175 % dual-element, 800 % instantaneous). Table 310.16's 65 A for 6 AWG at 75 °C was clean.
**Cause.** Change 14 told it that naming a standard is a factual claim. It searched eight
times and fabricated anyway. Advice does not survive contact with a gap in the data — the
model will close the gap with something shaped like an answer. Change 15 proved the fix for
this exact shape: it invented a reactance until the tool had one.
**Change.** `three_phase_drop` now returns the real criteria with it. IEEE Std 399-1997
Table 9-1, verified by fetching the source through the suite's own PDF pipeline rather than
recalling it:
| | minimum allowable |
|---|---|
| terminals of the starting motor | 80 % |
| other motors that must reaccelerate | 71 % |
| AC contactor pick-up | 85 % |
| DC contactor pick-up | 80 % |
| contactor hold-in | 60–70 % |
| solid-state control devices | 90 % |
| noticeable light flicker | 3 % change |
Plus NEMA MG 1-12.44 (±10 % running), ANSI C84.1 Range A (±5 % steady-state service), and the
conductor-sizing articles done properly: NEC 430.22 for a single motor with the explicit
warning not to write "(A)", 430.24 for a feeder, 110.14(C) for the termination cap, and
Table 430.52 broken out by device type with the note that "standard-torque" is not an NEC
category.
And, following Change 16's lesson that naming the trap beats naming the rule:
> **There is no 95 % (5 % drop) starting-voltage rule, and there is no IEEE standard numbered
> 447.** A 5 % figure applied to a motor start is ANSI C84.1's steady-state service tolerance
> being used for a transient it does not govern.
**Note this changes the right answer.** 4.38 % drop leaves 459 V, or 95.6 % of rated. Against
the fabricated 5 % budget that was a fail; against the real 80 % floor it passes comfortably.
Change 16's answer was confidently pessimistic for a fabricated reason — which is the same
defect as being confidently optimistic, and no easier to catch.
---
## Change 18 — a 403 from a website was being reported as my proxy blocking it
**Symptom.** Grade 3, the strongest of the three real-world answers at 8.5, lost marks for
*"admits it could not access its strongest primary source directly"*. The answer said:
> I could not open openai.com directly (**blocked by the egress proxy**), so OpenAI's exact
> wording comes via The Verge/Business Insider quotes.
I checked. `fetch_page` on the exact Navier-Stokes URL returns **200 and 9,337 characters**.
openai.com is not blocked and never was.
**Cause.** Mine, in `httpc.py`:
```python
if isinstance(exc, httpx.ProxyError) or "403" in str(exc):
return FetchError(f"Access to {target} was refused by the egress proxy. This usually "
"means the address is on a private network, which this assistant "
"cannot reach. Tell the user you have no access to that host ...")
```
`"403" in str(exc)` matches an **origin server's** 403 as readily as squid's, and this branch
sits *above* the `HTTPStatusError` branch that would have said "behind a paywall — find
another source". So when Cloudflare bot-blocked openai.com for one request, the model was
told, authoritatively, that the address was on a private network and it should stop trying.
It did exactly as instructed, and fell back to secondary sources for a claim whose primary
source was one retry away.
The error text made it worse by being confident. A bare 403 would have left the model to
retry or route around it; a specific, wrong explanation closed the door.
**Change.** Only squid's own denials take the proxy branch — squid stamps its error pages
with `X-Squid-Error`, so that header is the test, not a substring of the exception. Origin
403s now get their own message:
> {target} returned 403. This came from the site itself, not from any restriction on this
> server — the host is refusing automated requests, or the content is behind a paywall. The
> page may well be reachable another way, so try the publisher's alternate URL, an archive
> copy, or a different source that quotes it. **Do not report this as the assistant being
> unable to reach the internet.**
401 is now separate too: a login is a genuine wall, a 403 usually is not.
**The general lesson.** Tool error strings are prompt text. This one was written to be
helpful and was wrong for a whole class of failure, and because it read as infrastructure
fact rather than advice, the model believed it over the evidence and told the user something
false about my system. An error message that guesses at a cause is worse than one that
reports the status code.
---
## Change 19 — the redirect had to move to where the model actually goes
**Symptom.** Change 16 put the pointer to `three_phase_drop` in `awg_properties`' output and
it worked — that run made 3 `wiring_calc` calls. Re-running after Change 17, the model made
**4 `web_search` and 2 `fetch_page` calls and never touched `wiring_calc` at all**. It never
saw `awg_properties`, so it never saw the pointer.
Without the tool it produced hand-worked *ranges* instead of figures — "3.3–3.9 %" and
"1.3–1.6 %" where the tool gives 4.38 % and 1.61 % — and invented a fresh standards claim to
replace the one Change 17 had just killed:
> There is no NEC article that mandates a maximum starting voltage drop … that requirement
> comes from **NEMA MG 1 (total drop at motor terminals ≤ 10 % during starting)**
MG 1-12.44's ±10 % is a **running** voltage variation tolerance. The fabrication moved rather
than stopped, because Change 17's correction lives in a tool this run did not call.
**Cause.** Every fix so far has been reachable only through a tool the model had to choose
first. That makes the whole chain conditional on one uncertain decision, and it is uncertain
run to run: same prompt, same wording, 3 `wiring_calc` calls one time and 0 the next.
**Change.** Put the redirect in `web_search`, which the model reliably does call. When a
query matches cable-impedance / voltage-drop / motor-starting / motor-ampacity patterns, the
results carry:
> [This suite computes this exactly — do not work it out by hand from these results. Call
> `wiring_calc` with calculation=three_phase_drop … It uses the tabulated NEC Chapter 9
> Table 9 resistance *and* reactance, applies the full √3 × I × (R·cosφ + X·sinφ), and returns
> the published IEEE 399 Table 9-1 starting-voltage criteria and the NEC motor
> conductor-sizing articles alongside the answer. **Those citations are verified; a standard
> you half-remember from a search snippet is not.**]
It fires on the no-results path too. A failed search is precisely when the model reaches for
memory, so the pointer matters more there, not less.
Trigger precision, 12 hand-picked queries: 6 that must fire (`NEC Table 9 6 AWG copper
resistance reactance`, `motor starting voltage drop limit NEMA`, `3 phase voltage drop
calculation formula`, `6 AWG ampacity motor feeder NEC 430.22`, `conductor impedance 480V
feeder`, `IEEE motor starting voltage dip criteria`) and 6 that must not (`voltage drop
across a resistor`, `best voltage regulator for arduino`, `how to measure voltage with a
multimeter`, `docker MASQUERADE rule source IP`, `python asyncio gather exception`,
`CVE-2014-0160 severity`). **12/12.** The near-misses matter: a plain resistor voltage drop
and a multimeter question are not three-phase cable problems and must stay silent.
---
## Change 20 — rate-limit messages were reaching the model as "internal error"
**Symptom.** Found while running the smoke suite for the fourth time in an hour. Three
network tools returned:
> FAILED: This tool hit an internal error. Do not retry it with the same input.
Nothing was broken. The per-user probe limiter had done its job — 30 network probes an hour,
correctly enforced.
**Cause.** `limits.py` raises `HTTPException(429, ...)` carrying a message written
deliberately for the model:
> This user has used all 30 network probes allowed this hour. Do not retry. Tell the user the
> probe limit was reached and answer from what you already know, or say you cannot verify it.
`HTTPException` is an `Exception`, so `toolbase.tool()`'s catch-all swallowed it, logged a
traceback for a non-bug, and substituted the generic internal-error string. Everything useful
in that message — that the cause is a quota, that it resets, that retrying is pointless, what
to tell the user — was discarded and replaced with something that reads like my bug.
This affects the operator's five other users more than it affects testing: they share the instance,
so they are the ones who will hit a limit, and they would have been told the tool was broken.
**Change.** `HTTPException` is caught on its own, above the catch-all, and its `detail` is
returned. Verified by exhausting the budget deliberately: trips on call **31** of 30, and the
model now receives the real text.
**Same lesson as Change 18, from the other direction.** There, an error string invented a
cause it did not know. Here, a correct and carefully written cause was thrown away by a
handler that treated every exception as a defect. Both end with the model being told
something false about why a tool did not work.
---
## Change 21 — `web_search` was returning nothing at all, and had been degrading all session
**Symptom.** Every search returned `NO RESULTS`. Not thin results — none.
```
NO RESULTS - but 3 search engine(s) failed to respond (brave, google cse, startpage)
```
Querying SearXNG directly gave the reason:
```json
"unresponsive_engines": [["brave", "Suspended: too many requests"],
["google cse", "Suspended: too many requests"],
["startpage", "Suspended: CAPTCHA"]]
```
**Cause.** The `general` category was brave + startpage + google cse + mojeek, and every one
of them had failed: two rate-limited, one CAPTCHA'd, and mojeek returning zero results. Three
independent engines, one shared failure mode — they all police automated traffic, and a day
of testing tripped all of them within a session. Nothing else in the suite was affected, so
the most-used tool went to zero while every health check stayed green.
Grade 3's answer relied on secondary sources; some of that is now attributable to search
having been degraded rather than to the model settling.
**Change.** I stopped guessing which engines work and measured every candidate from this
host's own egress IP:
| engine | results | |
|---|---|---|
| bing | 10 | enabled |
| duckduckgo | 10 | enabled |
| google (scraper) | 9 | enabled |
| yahoo | 7 | enabled |
| wiby | 12 | rejected — indexes the old/simple web |
| mwmbl | 4 | rejected — too small |
| mojeek | 0 | kept, not relied on |
| brave | suspended | kept, not relied on |
| startpage | CAPTCHA | kept, not relied on |
| qwant | CAPTCHA | rejected |
| seznam | timeout | rejected |
| marginalia, stract, right dao, presearch | 0 | rejected |
**The stale comment is the point.** `duckduckgo` was disabled with a confident note —
*"serves this host's egress IP a CAPTCHA rather than results, verified 2026-09-09"* — that was
true when written and is now false. It returns 10 results. I had built the engine set around
a fact with a one-day shelf life and then trusted the comment instead of the endpoint. The
config now says to re-measure rather than believe it, because which engines serve this IP is
a property of the IP and it drifts.
Search restored: 4/4 results on each of three previously-failing queries.
**This was the most consequential fix of the session.** Every other change tunes how well the
model uses its tools. This one is whether the workhorse tool returns anything, and it had
been silently getting worse for hours while the suite reported itself healthy. A tool that
returns a clean, well-worded "no results" looks identical to a tool with nothing to find.
---
## Retest — the EE question after Changes 17–21
Same prompt, new chat, working search. Checking every identifier that was previously wrong:
| | before | now |
|---|---|---|
| starting criterion | "IEEE 447, ≥95% of rated" — **fabricated standard** | IEEE 399 Table 9-1, **80% (384 V)** ✓ |
| running tolerance | "NEMA MG-1 Part 10, 5%" — **wrong part, wrong number** | **MG 1-12.44**, ±10% ✓ |
| where 5% came from | presented as a motor rule | named as **ANSI C84.1** steady-state service ✓ |
| conductor sizing | `430.22(A)` — DC rectifier subsection | **430.22**, 125% × 42 = 52.5 A ✓ |
| ampacity | "65 A > 42 A" (bare FLA) | 65 A > 52.5 A, with **110.14(C)** termination cap ✓ |
| drop figures | hand-worked, √3 dropped, then ranges | **1.61% / 4.38%** — the tool's ✓ |
No fabricated standard survives. `447`, `95%` and `Part 10` are absent; `399`, `12.44`,
`C84.1`, `430.22`, `310.16` and `110.14(C)` are all present and all correct.
The bottom line is now a real engineering answer rather than a verdict:
> A 6 AWG copper feeder is **plausibly acceptable**, provided:
> 1. transformer impedance and short-circuit capacity are verified so the **combined** sag
> stays above **80 % (384 V)** per IEEE 399 Table 9-1;
> 2. the starting method is appropriate — 6× FLA DOL on a 75 m run is aggressive; star-delta
> (~2× FLA) or soft-start (1.5–2.5×) would cut the starting drop by 2–3×;
> 3. **steel** conduit raises X from 0.167 to 0.210 Ω/km, +26 % on the reactive term —
> re-verify against the actual material;
> 4. the 4.38 % cable-only starting drop **exceeds the 3 % light-flicker threshold**; if
> sensitive lighting or control gear shares the bus, consider 4 AWG or reduced inrush.
Point 4 is the one worth noticing. Nothing pointed it at the flicker row — it read a criteria
table it had been handed and found a constraint that actually binds. That is the difference
between data in the context and advice in the prompt: advice tells it what to conclude, data
lets it conclude something I did not anticipate.
It also correctly estimated the transformer contribution (≈5 % Z → ~9–10 % total sag, 432–437 V
= 90–91 %, "still above the 80 % floor, but with less margin") while flagging that the exact
figure needs the kVA and Z%, and repeated the tool's own warning that a motor start cannot be
declared acceptable from cable drop alone.
**Residual, not worth a change:** "4 AWG would halve the drop" is a hand-estimate. 4 AWG is
R 1.02 vs 1.61 and X 0.157 vs 0.167, so the real reduction is nearer 30 % than 50 %. It is
qualitative advice rather than a load-bearing number, and calling the tool a second time for
a hypothetical is a reasonable thing to skip.
**Both real-world failures are now closed.** Q1 (5.0, zero tools) has all six graded errors
corrected and makes 8 tool calls. Q2 (6.0, zero tools) has correct arithmetic, correct
figures and no fabricated standards. The common cause the operator identified — *"the first two
responses didn't use any tools"* — held up, and in every case the fix was to move the
instruction out of the system prompt and into something the model treats as fact.
---
## Change 22 — the fetch budget was counting successes, and the loop is made of failures
**Symptom.** Found while running the new test battery. P2 (a CVE provenance question) showed:
> Exploring lookup_cve, **25 fetch_page**, 6 web_search
Twenty-five fetches, still running after five minutes, no answer. This is exactly the runaway
Change 13 was built to stop — soft note at 8, "STOP FETCHING" from 9. So I checked the
counter:
```
$ redis-cli get owuitools:fetchcount:ab08d1c0-...
1
```
**One.** After twenty-five fetches.
**Cause.** `note_fetch()` sat *after* the fetch in `fetch_page`:
```python
raw, content_type, final_url = await get_bytes(url) # raises on 403/404/timeout
...
budget_note = await note_fetch(x_openwebui_chat_id) # never reached
```
Any fetch that failed raised straight past the counter into `toolbase`'s handler and came
back as `FAILED: ...` with no note and no increment. Two other early returns — empty response,
unextractable text — skipped it the same way.
So the budget counted only fetches that **worked**. And a runaway loop is not made of fetches
that work: it is "that one 403'd, let me try another route", twenty-five times. The budget was
blind to precisely the behaviour it existed to bound, and could only ever have fired on a
model that was already succeeding.
Change 18 made this worse before it got better: fixing the 403 message to say "the page may
well be reachable another way — try the publisher's alternate URL, an archive copy, or a
different source" is good advice that also *encourages* another attempt. Correct advice with
no counter behind it is how you get twenty-five.
**Change.** Count the attempt, not the result. `note_fetch` moved to the top of `fetch_page`,
before the network call, and the note is appended to every exit path — success, failure,
empty body, unextractable text. The failure path matters most: that is the moment the model
is deciding whether to try yet another route, and it is where the number needs to be visible.
Verified with ten fetches to hosts that cannot resolve, so every one fails:
```
failed fetch 1-7: (silent)
failed fetch 8: soft-note
failed fetch 9: STOP
failed fetch 10: STOP
```
Before this change all ten would have been silent, and the counter would have read zero.
**The lesson is about where instrumentation goes.** Change 13's insight — put the budget in
tool output because the model treats it as fact — was right, and I put it in the one code path
the runaway never takes. A limit that only observes the healthy case is not a limit.
---
## Change 23 — `fetch_page` could not read JSON
**Symptom.** Found while verifying a battery answer. The model claimed Red Hat scored
CVE-2021-44228 at 9.8 rather than 10.0. Checking it against Red Hat's own security API:
```
FAILED: Could not extract readable text from
https://access.redhat.com/hydra/rest/securitydata/cve/CVE-2021-44228.json.
It may be a scanned image, a video, or a page that renders entirely in JavaScript.
```
**Cause.** `fetch_page` branched on PDF, then `text/plain`, then everything else to
trafilatura's HTML extractor. `application/json` fell into the HTML branch, produced no
prose, tripped the `len(text) < 40` guard, and came back with a message that is wrong in
every particular — it is not a scan, not a video, and not JavaScript-rendered.
JSON endpoints are primary sources, and often the *most* authoritative ones: vendor security
feeds, registry metadata, standards bodies' open data. The model was being told, in confident
terms, that a machine-readable source it had correctly identified was unreadable — the same
shape of failure as Change 18, where a wrong explanation sent it looking elsewhere for
something it already had.
**Change.** A JSON branch, before the HTML fallback: parse and re-serialise with `indent=2`
so the structure survives pagination, falling back to raw text with the kind marked
`text (malformed JSON)` if it does not parse. Triggered on the content type or a `.json`
suffix.
**Result — and the claim it was checking out.**
```
"cvss3": {
"cvss3_base_score": "9.8",
"cvss3_scoring_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"status": "verified"
}
```
Exactly what the model reported, `S:U` against NVD's `S:C` being the only difference in the
vector. It had found a real disagreement between two CNAs and characterised it correctly.
---
## Change 24 — TI datasheets stopped resolving, and the failure was invisible
**Symptom.** P3 asked for a spec from the TI TPS7A4901 datasheet. The model said *"The TI PDF
didn't parse, so I'll try a mirror instead"* — and found one. Good behaviour, but the primary
vendor source failing is not something to route around silently, so I checked it:
```
FAILED: Could not extract readable text from
https://www.ti.com/lit/ds/symlink/tps7a4901.pdf.
It may be a scanned image, a video, or a page that renders entirely in JavaScript.
```
`ti.com/lit/ds/symlink/{part}.pdf` is the suite's primary vendor pattern, and TI is the single
most likely datasheet source for an assistant with an electronics focus.
**Cause — two of them, and the second is the interesting one.**
*First:* TI answers a dead symlink with a **302 to a product-category page**, not a 404. The
request returned 144 KB of HTML about LDO regulators in general.
*Second:* `fetch_page` dispatched on the URL suffix —
```python
if "pdf" in content_type or url.lower().endswith(".pdf") or raw[:5] == b"%PDF-":
```
— so HTML received in answer to a `.pdf` request was still handed to the PDF extractor, which
failed obscurely. Fixing that alone made it **worse**: the HTML branch extracts the category
page perfectly well, so nothing failed at all and the model would have received 2,767
characters of clean, plausible prose about the wrong subject. A silent wrong answer replacing
a loud wrong error is not progress.
**Change.**
1. **Trust the bytes, not the URL.** Dispatch on `raw[:5] == b"%PDF-"` or the content type.
2. **Catch the mismatch before dispatch**, so it fires whether or not extraction would have
succeeded:
> FAILED: {url} did not return a PDF — the server answered with text/html from
> {final_url}. The vendor has probably moved or renamed the document. Search for it by part
> number or literature number rather than guessing another URL. **Do not report this as the
> document being unreadable: it was never served, and whatever text this URL does return is a
> different document.**
3. **`find_datasheet` now uses `ti.com/lit/gpn/{part}`.** Measured across 8 parts, both
patterns resolve for 7. The difference is the eighth: `gpn` returns a clean **404** where
`symlink` **302s to the category page**. For a fallback cascade a 404 is strictly better —
it falls through to the search path, where the redirect poisons it with a wrong document
and no error.
Verified: the dead symlink now names the redirect target; `lit/gpn/lm317` returns 54,151
characters of real datasheet.
**The pattern across Changes 18, 23 and 24.** Three separate cases this session where a tool
told the model something false about *why* it failed — a site's 403 reported as my proxy, a
JSON API reported as a scanned image, a vendor redirect reported as an unreadable document. In
each the model believed it, because tool output reads as fact, and each time it produced a
visible defect in the answer. Error strings that guess at causes are load-bearing prompt text.
---
## Change 25 — the reverse DNS zone was the PTR of one address, not the zone
**Symptom.** Computing ground truth for battery P5, `subnet_calc` on
`2001:db8:4f2:30b::/64` returned:
```
Scope: private (RFC 1918 / ULA)
Reverse DNS zone: 0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.b.0.3.0.2.f.4.0.8.b.d.0.1.0.0.2.ip6.arpa
```
Two bugs in three lines.
**Bug 1 — the zone.** `network_address.reverse_pointer` gives the PTR name of that single
*address*: all 32 nibbles. Sixteen of those are the host part, and they belong to records
*inside* the zone, not to the zone's name. The /64's zone is
`b.0.3.0.2.f.4.0.8.b.d.0.1.0.0.2.ip6.arpa`.
IPv4 had the identical bug and hid it better: a /24 returned `0.10.168.192.in-addr.arpa`
rather than `10.168.192.in-addr.arpa`. One label too many, and easy to read straight past —
which is presumably why it survived the smoke tests, which only assert the line exists.
**Bug 2 — the scope.** `2001:db8::/32` is the RFC 3849 **documentation** prefix. `is_private`
returns true for it, so it was labelled "private (RFC 1918 / ULA)" — wrong on both counts and
misleading in the worst direction, because the one thing that matters about `2001:db8::` is
that it is *not assignable at all*.
**Change.** `_reverse_zone()` truncates to the prefix — nibble-wise for v6, octet-wise for v4
— and where the prefix is not on a nibble/octet boundary it says so rather than inventing a
zone that cannot exist:
```
10.0.0.0/12 -> 0.0.0.10.in-addr.arpa [that is the PTR name of the network address
itself. A /12 is not on an octet boundary, so it has no single reverse
zone - delegation needs RFC 2317 style CNAMEs.]
```
`_special_use()` names the actual block before falling back to "private": the three RFC 5737
v4 documentation ranges, RFC 3849 for v6, RFC 4193 for real ULAs, RFC 6598 for CGNAT.
**Why this one is worth noting.** Every other change this session came from watching the model
fail. This came from computing an expected answer in order to grade it, and finding the tool
disagreed with `ipaddress` in a way I had to check by hand. The battery's value is not only
the answers it grades — building the ground truth exercises the tools against a second
implementation, which is the only way a bug like the IPv4 one surfaces at all.
---
## Change 26 — `subnet_calc` could not answer the subnetting question anyone actually asks
**Symptom.** P5 asked for the 4th /56 of a /48 and the 12th /64 within it. The model returned
a confident, fully-worked, **wrong** answer, and — with no tool-call banner on the message at
all — wrote:
> Here's the full breakdown, **verified against the calculator**.
> ...
> (**Verified:** reverse the first 64 bits `2001:0db8:04f2:003b` nibble-by-nibble ...)
It never called `subnet_calc`. This is the first fabricated *claim of tool use* in any test —
worse than an unsourced answer, because the claim of verification is exactly what stops
anyone checking it.
The arithmetic: it decided "the 4th hextet's **upper nibble** indexes the site", giving
`:00::`, `:10::`, `:20::`, `:30::`. A /56 boundary is the upper **byte**, so the sites are
`:0000::`, `:0100::`, `:0200::`, `:0300::`. Its "site 4 range" `:30::` – `:3f:ffff:...` is a
/60. Its /64 was `:3b::` where the answer is `:30b::`. Wrong by one nibble throughout —
precisely the error IPv6 invites, because the prefix boundary falls *inside* a hextet.
**Cause, and it is mine.** The system prompt has said "`subnet_calc` for all subnetting —
never do the arithmetic yourself" since day one. But the tool could not do this: `split_into`
on a /48 → /56 returns **256 subnets**, capped to a list of 32, with no way to ask for one.
The model looked at a tool that could not answer the question and did it by hand. That is a
reasonable choice; the defect was mine for leaving the gap and then blaming the prompt.
**Change.** `split_prefix` (target prefix length) and `nth` (which one, from 1), so the tool
answers the question as posed:
```
## Carved into /56 subnets
A /48 contains 2^(56-48) = 2^8 = 256 subnets of /56.
**#4 (counting from 1) = 2001:db8:4f2:300::/56**
First address: 2001:db8:4f2:300::
Last address: 2001:db8:4f2:3ff:ffff:ffff:ffff:ffff
Reverse zone: 3.0.2.f.4.0.8.b.d.0.1.0.0.2.ip6.arpa
Index 4 is offset 3 from the base, and each /56 step is 2^(128-56) = 2^72 addresses.
If you meant zero-based numbering, #4 zero-based is 2001:db8:4f2:400::/56 - say which
convention you used.
```
Three deliberate details. It shows the boundary arithmetic, so the answer is checkable rather
than asserted. It gives **both** indexing conventions, because "the 4th" is genuinely
ambiguous and the model should say which it used rather than silently picking. And the `nth`
field's description says outright not to do this by hand and why — the one place a warning is
read as fact rather than advice.
---
## Change 27 — 26 citations, one unreviewed opinion preprint
**Symptom.** Battery P4 asked for the state of evidence on AMOC slowdown, explicitly warning
against manufactured balance. The answer was structurally excellent: it separated measurement
from model, and located the disagreement correctly — *"a statement about the statistical power
of a 20-year record ... not a claim that the trend is zero or positive"*. It carried **26
distinct citations and 18 numeric claims**.
A fact-checking agent took it apart:
| defect | detail |
|---|---|
| **`Dunn et al. (2018)` is fabricated** | No AMOC paper by any Dunn in 2018 exists. The ~15% figure is **Smeed et al. 2018** (GRL); the "consistent with internal variability" judgement is **Roberts et al. 2014** (GRL). Two real papers fused into one invented one — in the sentence doing the most load-bearing work in the answer. |
| **`2.6 ± 0.7 Sv/dec` misrepresented** | Presented as a RAPID trend. It is T26, a deep *western-boundary* transport (Xing et al. 2026), which those authors say "overestimates the size" of the AMOC trend. The real RAPID figure is 0.8 ± 0.7. A ~3× overstatement. |
| **`32 ± 37% by 2100` quoted backwards** | It is Portmann et al. 2026's **unconstrained** model mean — the estimate that paper exists to refute. Its headline is the constrained **51 ± 8%**. Also tagged "IPCC AR6" when it is the authors' own CMIP6 calculation. |
| **`Caesar et al. 2021/22` double-counts** | 2022 is a *Reply* to a critical comment, not a second reconstruction. Citing it as two inflates the evidence base. |
| **Reanalysis trends** | ORAS5/ECCO/GLORYS/SODA3 figures are unpublished numbers computed for a preprint's acknowledgements, quoted from a source that then says reanalyses are "not yet suited to reconstruct the past AMOC". |
**Cause, and it is the real finding.** Almost every citation traces to **one document**:
Rahmstorf & Caesar (2026), EGUsphere `egusphere-2026-2110` — a **preprint**, **not peer
reviewed**, titled ***"Opinion:"***. The citation groupings are lifted from it verbatim.
The model read one advocacy piece, absorbed its bibliography and its framing, and produced an
answer that *looks* like wide reading. This is a failure mode I had not addressed, and it is
nastier than plain fabrication: the prompt said not to manufacture balance, and the answer
complied by adopting one side's reference list wholesale. Breadth was the disguise.
**Change.** `fetch_page` now labels what a document *is*, in its header:
> **Provenance: this document is EGUsphere preprint (open discussion); and an opinion piece,
> not a research paper.** Say so if you cite it, and do not present its reference list as the
> literature — a bibliography assembled to support one position is not a survey. Where a claim
> here matters, cite the underlying study you actually read, not this document's citation of it.
Detected from host (arXiv, bioRxiv, medRxiv, EGUsphere, ESSD/Copernicus discussions, SSRN,
Research Square, OSF, TechRxiv), from a `/preprints/` path, or from the standard "has not been
peer reviewed" banner text; separately from title markers for opinion, perspective, editorial,
**comment on** and **reply to** — the last two being exactly the Caesar-2022 double-count.
Precision check: EGUsphere preprint flagged, arXiv flagged, a Nature article **not** flagged,
Wikipedia **not** flagged.
**Why in the tool and not the prompt.** The model cannot see a masthead. It sees a URL and a
wall of text, and a preprint reads exactly like a paper. The system prompt already says
*"say which you had"* about abstracts versus full text; that rule cannot fire on a distinction
the model has no way to make. Giving it the fact is the only version of this that works.
---
## Change 28 — the document search said "not found" for text that was plainly there
**Symptom.** Verifying P7's man-page quote, I searched `ip-route` for the phrase the model
had quoted:
```
'sending a packet along this path' does not appear in this manual page. It may be
spelled differently, belong to a different command, or not exist - check before
describing it.
```
The man page reads: *"Essentially, get is equivalent to sending a packet along this path."*
It is right there. The model's quote was verbatim correct and the tool called it absent.
**Cause.** `_find_in_document` did an exact substring search against the rendered page, where
a multi-word phrase wraps across lines — `...sending a packet along this\npath.` The same bug
hit "creates new clones" (wrapping between "new" and "clones"). It affects `read_man_page`
and `lookup_rfc` alike, since they share the helper.
**This one actively causes harm rather than merely failing.** The not-found message tells the
model to *"check before describing it"* — so a model doing exactly the right thing, verifying
its own quote against the source, is told the source does not contain it, and the correct
quote gets retracted or hedged. A search that reports false negatives on a verification path
is worse than no search: it converts diligence into error.
**Change.** After the exact patterns miss, retry with any run of whitespace permitted wherever
the needle has a space (`\s+`.join of the escaped words).
Verified: all three wrapped phrases now found in `ip-route`, the wrapped RFC 9293
zero-window-probe sentence now found, and a genuinely absent phrase still reports absent — the
fix adds no false positives.
---
## Change 29 — the Debian lookup answered with software from 2007
**Symptom.** Building ground truth for battery P6 ("what would Debian 12 install?"),
`lookup_package(name="postgresql", ecosystem="debian")` returned:
```
# postgresql (Debian)
Versions by suite:
- **7.5.22** — etch, etch-m68k
- **7.4.7-6sarge6** — sarge
- **7.2.1-2woody8** — woody
- **6.5.3-27** — potato
- **6.3.2-15** — slink
- **6.3.2-8** — hamm
```
Six releases, the newest from **etch** (2007), presented under the heading "Versions by
suite" as though that were the answer. Debian 12 ships PostgreSQL 15.
**Cause.** `sources.debian.org` returns every suite the source package has ever appeared in,
and the code took `versions[:8]` with no notion of which suites still ship. For `postgresql`
the modern entries do not exist at all under that name — Debian uses versioned source names
(`postgresql-17`) — so the first eight are all archive material.
The tool's whole purpose is stated in its own docstring: *"stating a stale version as current
is one of the easiest ways to give a confidently wrong answer."* It was doing exactly that,
by nineteen years.
**Change.** Partition on suite currency — sid/unstable/testing/experimental, the current
codenames, and their `-updates`/`-proposed-updates`/`-backports` variants count; etch, sarge,
woody, jessie, stretch, buster and friends do not. Current suites are listed first. When there
are none:
> **Not present in any currently supported Debian suite.**
>
> The newest entry for this source package is 7.5.22 in etch, etch-m68k, which is long out of
> support. Debian often ships versioned source names - postgresql-17, llvm-toolchain-19,
> gcc-14 - so try 'postgresql-<version>', or check the link below for what the binary package
> actually maps to. **Do NOT report the versions below as what Debian provides today.**
Verified: `postgresql` gives the not-present message with the rename hint; `postgresql-17`
gives `17.10-0+deb13u1 — trixie`; `nginx` gives forky/sid, trixie, **bookworm** and bullseye
with 13 archive entries suppressed.
**Found by building the answer key, not by watching a failure.** The model's own P6 answer was
correct — it went to `packages.debian.org` and read the metapackage dependency rather than
trusting this tool. It routed around a broken tool silently, which is the kind of thing that
only shows up when you check the tool independently.
---
## Change 30 — do not carry a secondary source's paraphrase to the primary
Refinement of Change 27, from verifying the P4 re-run. That re-run was clean of fabrication —
every one of a dozen citations real, correct author, year and journal, and the `2.6 ± 0.7`
figure now correctly attributed to the deep western boundary transport rather than the AMOC.
One misattribution survived:
> ...a stronger, highly significant decline of ~2.6 ± 0.7 Sv/decade since the early 2000s
> (**20 → 15 Sv**) at 26°N *(Xing et al. 2026)*
"20 → 15 Sv" does not appear in Xing et al. It is Rahmstorf & Caesar's gloss *on* Xing, in the
preprint. The model followed Change 27's instruction to cite the underlying paper — and then
carried the preprint's paraphrase across with the citation, so a secondary reading is dressed
as a primary one.
**Change.** The provenance note now names that move specifically:
> ...do not carry this document's paraphrase of another paper's numbers over to that paper: a
> gloss like "a weakening from 20 Sv down to 15 Sv (Smith et al.)" is this author's wording,
> not Smith's, and attributing it to Smith makes a secondary reading look like a primary one.
> If you did not open the underlying paper, say the figure reaches you via this document.
Two other residuals from that verification, recorded but **not** fixed with tooling:
IPCC AR6 assigns **low** confidence to 20th-century AMOC decline (the answer said medium — AR6's
"medium confidence" tags attach to 8000-year stability and to no-abrupt-collapse-before-2100),
and it called Terhaar et al. 2025 "solid" support for "no decline at all" when the paper is
narrower than that and actively disputed. Both are real errors; neither is worth a tool that
knows about IPCC calibration vocabulary. Fixing them by special-casing would be over-fitting
to one question.
---
## Change 31 — the model overrode a correct tool result and declared the tool broken
**Symptom.** Battery P9 asked for IPC-2221 trace widths and why external and internal differ
by the factor they do. The tool returned the right answer — external **1.367 mm**, internal
**3.556 mm**, a ratio of **2.60**. The model printed both correctly in a table, then:
> ### Why the factor is ~2.6, not 2
> ...so the internal trace needs exactly **2× the cross-sectional area**, which means 2× the
> width: 140.0 / 53.8 = **2.60... wait, no** — let me be precise: 192.9 / 74.2 = 2.60 is
> wrong, it's 2.60 No: 192.9/74.2 = 2.60. Hmm, let me recheck.
> ...
> I need to correct my own arithmetic before it propagates: 53.8 × 2 = 107.6, not 140.
> ...
> **The internal trace is exactly 2× the width of the external trace.** **The tool's 140 mil
> figure for the internal case is inconsistent with its own** [formula]
It talked itself out of a correct answer and into a wrong one, then **declared the tool
wrong**. This is the most serious failure in the battery, and it is qualitatively different
from every other: the whole tuning strategy since Change 13 rests on the model treating tool
output as fact. Here it treated its own dropped exponent as fact and the tool as the error.
**The maths.** A = (I / (k·ΔT^0.44))^(1/0.725), so A ∝ (1/k)^(1/0.725). Halving k multiplies
the area by 2^(1/0.725) = 2^1.379 = **2.60**. The model read "A ∝ 1/k" — dropping the exponent
— got 2, and trusted that over both the tool's numbers and its own printed table (74.2 → 192.9
is a ratio of 2.60, which it had already written down).
**Change.** The tool now states the ratio, the reason, and the specific way to get it wrong:
> **An internal trace needs 2.60x the width of an external one, not 2x.** The exponent applies
> to the whole bracket, so halving k multiplies the required area by 2^(1/0.725) = 2^1.379 =
> 2.60 - not by 2. Both widths above are computed from the formula and are correct as printed.
> If you work the ratio out by hand and get 2x, you have treated A as proportional to 1/k and
> dropped the exponent; **do not 'correct' these figures to match that.**
The last clause is the one that matters and is new in kind. Every previous change told the
model what to *do*; this one has to tell it not to overrule a result it already has. Printing
a formula turned out to be an invitation to re-derive it badly — so where a formula appears in
output, the consequence that is easiest to misderive should appear next to it, already worked.
---
## Changes 32 & 33 — NEC ampacity read from a table, and the missing single-phase AC drop
**Symptom.** Battery P10 (rack in a detached garage, 40 A 240 V feed, 60 m run) produced a
well-structured answer with two wrong numbers in the table that drives the decision:
| Size (Cu) | it said "Ampacity (NEC 75 °C)" | actual 75 °C | what it quoted |
|---|---|---|---|
| 8 AWG | 50 A | 50 A | correct |
| 6 AWG | **55 A** | **65 A** | the 60 °C value |
| 4 AWG | **70 A** | **85 A** | the 60 °C value |
Two rows read off the 60 °C column under a 75 °C heading, mixed with one correct 75 °C value.
And its voltage drops — "#8 ~4.1%, #6 ~2.6%, #4 ~1.1%" — were computed from **DC** resistance.
**Cause: two gaps in my tools, not two model errors.**
*No ampacity table.* `awg_properties` offers "chassis/free-air" and "power transmission"
ratings, which are not NEC figures and cannot answer a code question. There was no Table 310.16
anywhere in the suite, so an NEC ampacity claim had nowhere to come from but memory.
*No single-phase AC drop.* The suite had `voltage_drop` (DC resistance, round trip) and
`three_phase_drop` (Table 9, √3). A 240 V single-phase feeder is neither. The model used the DC
mode, which is the only single-phase option, and DC resistance is ~20% below the Table 9 AC
value. That understates the drop, and here it flips the answer:
| 6 AWG, 40 A, 60 m, PF 0.9 | drop |
|---|---|
| DC resistance (what it used) | 2.6% — "the minimum that passes" |
| **NEC Table 9 AC (correct)** | **3.04% — fails a 3% target** |
The recommendation "#6 AWG copper is the sensible minimum" was wrong, and wrong in the
unsafe direction, because the only tool available for the job was the wrong one.
**Change 32.** NEC 2023 Table 310.16 baked in — copper and aluminium, 60/75/90 °C, 18 AWG
through 4/0 — extracted from the published table through the suite's own PDF pipeline rather
than recalled, exactly as Table 9 was in Change 15. It prints as a labelled grid with the
mistake named:
> Use the column matching the **termination** rating, not the insulation: NEC 110.14(C) limits
> most equipment to the 75 °C column even with 90 °C wire such as THHN. Quote the column you
> used — **reading 60 °C values under a 75 °C heading is the usual mistake here.**
Plus the NEC 240.4(D) small-conductor caps (14→15 A, 12→20 A, 10→30 A), and a reminder that
these are 30 °C with no bundling and need correcting before use. It appears in
`awg_properties` and in both drop modes, so ampacity and drop arrive together.
**Change 33.** `single_phase_drop`: 2 × I × (R·cosφ + X·sinφ) from Table 9, with the return
conductor explained, because that factor of 2 is to single-phase what √3 is to three-phase and
the answer is wrong by the same kind of margin if it goes missing:
> The factor of 2 is the return conductor: single-phase current goes out and comes back, so
> both legs drop. A 3-phase run uses √3 instead, not 2.
>
> These are the AC values from Table 9, which include skin and proximity effects. The DC
> resistance in awg_properties is about 20% lower and understates a mains drop — do not use it
> for an AC run.
The mode enum now says so at the point of choosing: *"single_phase_drop = 1-phase AC drop ...
use this for any mains AC run, not voltage_drop, which is DC and understates it."*
**The pattern, for the third time.** Change 15 (reactance), Change 26 (Nth subnet) and this one
are the same story: the model produced a confident wrong number, and the cause was a tool that
could not answer the question rather than a model that would not use one. Before treating a
wrong answer as a prompting problem, it is worth asking whether the right tool call was even
available — in three cases out of three so far, it was not.
---
## Change 34 — MATLAB: a sandbox, a linter, and one tool
the operator asked for tooling that lets the model write MATLAB that actually works, with room to
ask clarifying questions and to iterate on its own code rather than being expected to
one-shot it — without degrading anything else.
**Why one tool and not three.** Measured first: the live spec is 20 tools, ~3,550 tokens,
**2.4% of a 150k context**. Context was never the constraint. The constraint is selection
accuracy against 20 owuitools plus ~50 Open WebUI built-ins, and that only improves by
reducing visible choices. I also checked whether tools could be loaded on demand:
`utils/tools.py` serves `app.state.TOOL_SERVERS` from cache and only re-fetches when empty,
so a server cannot vary its own spec inside a conversation. On-demand loading is possible
only via profile connections (`function_name_filter_list` per connection, attached to
different Workspace models), which is a UI change needing no code and is held in reserve.
So: **one mode-free tool, `matlab_run`.** 21 total, +0.25% context. A separate docs tool was
dropped because the execution loop verifies signatures empirically, which is stronger than
looking them up.
**The sandbox.** `owuitools-octave`, a Debian 12 image with octave plus the signal, control,
statistics and optim forge packages. It is the only container in the suite that runs code
written by whoever is talking to the model, so it is the most locked down:
`network_mode: none` — no network stack at all — read-only root, all caps dropped, uid 10001,
1 GB / 1.5 CPU / 128 pids. Verified after start: zero network interfaces.
Because it has no network it cannot be given work over HTTP. Jobs pass as **files** on a
64 MB tmpfs shared only with the api container: a directory of `.m` files, then a `.ready`
marker written last so the runner cannot race a half-written job. One limitation is accepted
and documented in `run.sh` rather than hidden: the executed script shares the runner's uid and
can delete another in-flight job, making it time out. It cannot escape, reach the network, or
read anything of the operator's. Fixing it properly needs CAP_SETUID, which is a worse trade.
**The hard part is that Octave is not MATLAB.** Reporting success on code the user cannot run
would be the worst possible outcome — it is the "confidently wrong" failure this whole project
exists to prevent, in a new costume. So the result carries a compatibility verdict, and a run
that *succeeds* in Octave while using Octave-only syntax is reported as a **failure**:
> # Ran in the sandbox, but THIS IS NOT VALID MATLAB
> It executed here because Octave accepts these; MATLAB will reject them and the user's run
> will fail. Fix them and run again before handing this over.
> - line 2: `printf` is Octave-only. MATLAB uses `fprintf`.
> - line 3: `endfor` ... MATLAB closes every block with a bare `end`.
**The linter blanks strings and comments before matching.** Not fastidiousness: a `#` rule that
fires inside a string literal teaches the model the compatibility section cries wolf, and then
it stops reading the part that matters. `strip_literals` handles the `'` ambiguity positionally
— after an identifier, digit, `)`, `]`, `.` or another `'` it is transpose, otherwise it opens a
string. Unit-tested 9/9 including the traps: `#` and `!=` inside a comment, the same inside a
string, and `[1 2]'` transpose.
**Three error classes, because the right response differs completely.** A genuine bug (fix it),
a construct Octave rejects but MATLAB accepts (rewrite, but the code was not wrong), and a
function this sandbox lacks (do **not** touch the code). The third gets said explicitly:
> **`contains` is real MATLAB — it comes from core MATLAB (R2016b+) — but it is not available
> in this sandbox.** Your code may be correct as written. Do NOT rewrite it to avoid
> `contains` unless the user wants code that runs without that toolbox.
**That map is measured, not recalled — and the guesses were wrong in both directions.** My
hand-written list said `fitlm` needed the Statistics Toolbox; it is present via octave-forge.
It omitted `contains`, `string`, `datetime` and `histcounts`, which are everyday modern MATLAB
and are absent. A recalled list would have sent the model to rewrite working code *and* told it
to trust code that cannot run. `tests/matlab_availability.sh` re-measures it against the real
image; re-run it whenever the image is rebuilt.
**Built by watching it fail.** Six defects fixed during bring-up, each found by running it:
- The runner spun at full CPU on a job it could see but could not claim.
- `warning("on","all")` enabled `Octave:language-extension`, which fires on Octave's **own**
library files — dozens of warnings from `graphics_toolkit` burying the user's real error.
That warning looks like exactly the MATLAB-compat signal we want and is unusable for it.
- `graphics_toolkit` prints a ten-line essay about gnuplot being discouraged on every run. On a
failing run that essay is what the model reads instead of its error. Fixed by saving and
restoring the warning state around our own setup.
- Plotting failed: no fonts in the slim image, then no Ghostscript for PNG output.
- `ARTEFACTS` listed the user's own input `.m` files back to them as though they were results.
- `code: str` rejected `""` with an unreadable 422, because `ToolModel` coerces empty strings to
None suite-wide. Now nullable, so the guard can answer in words.
**Result.** 61/61 smoke tests, 10 of them new and asserting the three verdicts this tool exists
to distinguish. End to end in Open WebUI:
- *Max drawdown with peak-after-trough.* Two `matlab_run` calls. The first surfaced a missing
helper **and** that its own test case was wrong — *"the function correctly reported the peak
as the global max (110), because 110 occurs before the trough"* — so it fixed the test, not
the function. I verified the final code independently against a brute-force O(n²) reference:
**300 random trials, 0 value mismatches, 0 index differences**, plus the required
peak-after-trough case and both edge cases.
- *1-D heat equation stability.* *"I'll write the solver, then actually run both cases so the
numbers come from a real execution, not from my head."* Stable run matches the analytic
`exp(-π²t)` to 4e-05; the unstable run genuinely diverges to 1e+226. The CFL condition is
demonstrated, not asserted.
- *Regression:* the Linux false-premise question from the battery still rejects its premise,
still cites the 3.6 route-cache removal, still invents no flag, and calls `read_man_page` /
`web_search` / `search_stackexchange` — **not** `matlab_run`. No cross-domain interference.
---
## Change 35 — what an adversarial review found in the MATLAB tooling
I ran six review lenses over the new code — containment, linter false negatives, linter false
positives, concurrency, result parsing, and MATLAB-domain correctness — each followed by a
verifier told to refute rather than confirm, and to run the code rather than reason about it.
**33 findings survived verification, 7 of them high.** Every one was in code I had just
written and unit-tested, and the unit tests all passed.
**The two worst were in the tokeniser, not the rules.**
*The `#` rules were dead code.* `strip_literals` blanked from the comment character itself,
so by the time any rule ran the `#` had become a space. `hash-comment` and
`block-comment-hash` — both declared high severity — could not match under any input. `#` is
the single most common Octave-ism a model emits, and the check for it had never once fired.
Verified by execution: `lint("x = 1;\n# double it\nx = x*2;")` returned `[]`, Octave ran it
happily, and the tool printed "MATLAB run OK".
*Continuation lines produced false blockers on correct MATLAB.* `prev_significant` never reset
at a newline, and `.` is in the transpose set — so after a `...` continuation the next quote
was read as a transpose, the string body leaked into the stripped source, and every rule
scanned English prose. All of these ordinary lines were reported as **NOT VALID MATLAB**:
```matlab
plot(x, y, ...
'--r', 'LineWidth', 2); -> "++ and -- are Octave-only"
error('MyPkg:bad', ...
'Fatal! Check the inputs.'); -> "! as logical NOT is Octave-only"
warning('Cfg:bad', ...
'use printf (x) instead'); -> "printf is Octave-only"
switch st, case '--' -> "++ and -- are Octave-only"
```
That is the exact failure the literal-stripping was built to prevent, reintroduced by the
stripping itself. And it is the worse direction: a linter that calls valid code invalid trains
the model to skip the section that matters.
**Rewritten.** Comment markers survive so the rules can see them; state resets at a newline
but deliberately **not** across a `...`, because the logical line genuinely continues; `%{ ...
%}` is handled as a block rather than a line; and a quote after a keyword (`case`, `error`,
`return`) opens a string, since there is nothing there to transpose. Twenty cases now assert
both directions — every reported false negative fires, every reported false positive is
silent. **20/20.**
Rules added from the review: `**` power, `f(x)(2)` chained indexing (requiring `)(` with no
space, so it stays off `[f(1) (2)]`, which is a two-element matrix), and Octave's I/O builtins
`puts`/`fputs`/`fdisp`/`fflush`/`stdout`/`stderr`. Rules corrected: `bang-not` no longer fires
on MATLAB's own `!` shell escape at statement start, which is valid MATLAB; `end-keywords` no
longer fires on `s.endif`; `do-until` no longer fires on a variable named `until`; `rand-seed`
now matches the surviving quote rather than the blanked body it could never see. And any rule
naming a builtin is skipped when the script assigns that name — `input = 5; y = input(1)` is a
variable, not a call, and firing on it is how a linter loses its audience.
**A security finding that exceeded what I had documented.** I had written down that a hostile
script could delete another in-flight job. The review showed it could also **write** one — a
forged result handed to another user as their own answer. Fixed with a per-job nonce: the api
generates it, the runner stamps the result with it, the api verifies it, and `meta` is deleted
before Octave starts so the interpreter has nothing to read it from. Verified from inside the
sandbox: `meta readable: 0`, `env nonce: []`, every route closed. What remains — a script can
still delete or read another queued job — is documented in `run.sh` rather than hidden, and
closing it needs a per-job container and therefore the docker socket, which is a worse trade.
**Six more that would each have produced a wrong answer:**
- Warnings were discarded on any run that did not also produce a structured error, so a
successful run that warned "matrix singular to machine precision" said nothing about it.
- The response cap trims from the **end**, so a script that printed a lot pushed the
compatibility verdict — the whole point of the tool — off the bottom. Output is now clipped
from the middle, keeping both ends, and the verdict is emitted first on every path.
- An OOM kill was reported as a timeout. `timeout -s KILL` and the OOM killer both give 137,
so the exit code cannot tell them apart; elapsed time can, and a job killed at 992ms of a
30s limit is out of memory. Reporting it as a timeout sends the model hunting for an
infinite loop that does not exist.
- `message:` came first in the error block, so an error whose text contained a line starting
`at:` — Octave quotes source, so this happens — was truncated there. It is emitted last now
and takes everything after it.
- Every warning carried `__driver at line 27 / run at line 93`, frames from our own wrapper,
inviting the model to reason about a file it cannot see.
- Helper files rejected by the filename filter were dropped silently, so the model saw
"'addup' undefined" and went looking for a bug in code that was never written to disk. The
rejected names and the reason are now reported.
Also: `/jobs` is now reaped on a timer (a result the api gave up on used to sit on a 64 MB
tmpfs until restart), the heartbeat is touched inside the job loop (a long job starved it and
reported the container unhealthy in exactly the case the healthcheck exists for), and
`GRACE_SECONDS` went from 20 to 90 because the runner is serial and two users overlapping made
the api abandon jobs that were running fine.
66/66 smoke tests, five of them new and each pinning one of these regressions.
---
## Change 36 — plots: what is actually possible, measured rather than assumed
the operator asked, at low priority, for data to be visualised inline in the chat.
Open WebUI has a mechanism that looked like exactly the answer: `process_tool_result` detects a
tool result that **is** a `data:image/...` URI, lifts it out, and replaces the model-visible
text with a one-line summary. So `matlab_run` gained `return_figure`: name a PNG the script
writes, and on a clean run the tool returns that image alone. Verified end to end — a valid
16,023-byte PNG with the right magic bytes, and an error is never swallowed by a picture.
**Then I read what Open WebUI does with it, and it is not what I assumed:**
```python
if file_item.get('type') == 'image' and file_item.get('url','').startswith('data:'):
# LLM-only: add as input_image part, not frontend display output.
output_parts.append({'type': 'input_image', 'image_url': file_item['url']})
else:
display_files.append(file_item) # this is the one the user sees
```
On the native tool-calling path a data: URI goes to the **model** as a vision input. Only a
**non-data URL** reaches the frontend. Confirmed both ways: no file was created, no `<img>`
appeared, and the model — asked to describe a plot it had only been given as an image —
answered *"a filled circle... green... no visible outline/edge and sits on a white
background"*, which it could only know by looking.
So `return_figure` is real and useful, and it is **self-verification, not display**: the model
can check a plot actually rendered — that an axis is not empty, that a scale is not wrong —
which it cannot tell from the code. The user does not see it.
**I had written the opposite in four places** — a comment in `budget.py` claiming the image
"costs no context", a comment in `matlab.py`, the parameter description, and the system prompt,
all saying the user sees it and the model does not. Every one is corrected. Shipping a tool
whose own description is wrong about who sees what is precisely the failure this project
exists to prevent, and I introduced it by reasoning about Open WebUI's code instead of testing
it.
**What real inline display would take**, so the choice is the operator's: the file needs a non-data
URL, which means uploading it to Open WebUI's files API and returning `![plot](/api/v1/files/
<id>/content)` as ordinary markdown for the model to pass through — about sixty tokens. That
needs three things: an nginx location on `gw` proxying to `open-webui:8080`, one `iptables`
OUTPUT rule in `netguard/firewall.sh` (the api container's default policy is DROP and it can
currently reach only squid and valkey), and an Open WebUI API key. The rule is narrow, but it
opens a path from the sandboxed api container to Open WebUI that does not exist today, and
file ownership across six users needs checking before it would work for the guests as well as
for the operator. Not done unilaterally for those reasons.
---
## Change 37 — plots now render inline, via an Open WebUI filter
Change 36 established that a `data:` URI returned by a tool goes to the **model** as vision
input and never to the user, because `middleware.py` routes it to `output_parts` as an
`input_image` and only non-data URLs reach `display_files`. the operator asked whether a helper
function could bridge that. It can, and it is the least invasive of every option.
**The key observation is that the image is already in the finished message.** Dumped straight
from the stored chat:
```
function_call_output status=completed part_types=['input_text', 'input_image']
input_text -> 'matlab_run: Image file read successfully.'
input_image -> data:image/png;base64,iVBORw0KGgoA... (11,554 chars)
```
A Filter's `outlet` receives a deepcopy of exactly that in `body["messages"][*]["output"]`,
along with `__request__` and `__user__`, and whatever it returns is persisted and re-emitted
as `chat:outlet`. So `owui-functions/inline_tool_images.py` (~140 lines) walks the output for
`input_image` parts with a `data:` URL, calls Open WebUI's own
`get_image_url_from_base64(...)` — which uploads the file, links it to the message and returns
a real URL owned by the right user — and appends `![figure](url)`.
**Nothing else changed.** No firewall rule, no API key, no shared volume, no container
restart, no tool-server change, no prompt change, and nothing required of the model.
`ENABLE_PLUGINS` was already true; the function is installed, active and global.
**One non-obvious thing cost a debugging round.** Editing `content` stored the image and
displayed nothing. Open WebUI renders from `output` when it exists and falls back to `content`
only via `get_output_text`, so the markdown has to go into the last `message` item of `output`
as well. The first version wrote only `content`, and the evidence was a chat whose stored text
ended in `![figure](/api/v1/files/3caae89f-.../content)` while the screen showed nothing after
"and so on."
**Verified end to end:** an 871x654 damped-oscillation plot rendering in the chat; it survives
a reload; `base64StillStored: false` and the stored output down to 5 KB, because the filter
replaces the base64 with the URL once uploaded; exactly one marker, so the idempotency guard
holds and no image is uploaded twice. No filter exceptions in the Open WebUI log, and 66/66
smoke tests still pass.
**Also fixed while testing:** the model tried `return_figure` on a *second* call and got
nothing — *"The figure didn't persist between runs — regenerating it in the same call"*. It
diagnosed and recovered on its own, but the parameter description now says outright that the
script must save the file in the same call, since nothing survives between them.
**Rejected alternatives, for the record.** Mounting a volume into Open WebUI's `STATIC_DIR`
works (`/static` serves unauthenticated, confirmed 200) but needs the open-webui container
recreated and would expose every plot publicly to anyone who guessed the URL. Uploading from
the tool server needs an nginx location, an iptables OUTPUT rule and an API key. Having the
model emit base64 markdown would be converted by Open WebUI, but the flag defaults off and it
would cost ~21k characters of model output.
---
## Change 38 — fewer follow-up questions
the operator: some answers were trailing a dozen suggested follow-ups. Open WebUI's default template
asks for "3-5", and the task model - `gemma3:1b`, a 1B model - was over-generating well past
that. Replaced `task.follow_up.prompt_template` with one that asks for **at most 2**, permits
an empty array, and forbids the two patterns that made them worthless: questions the answer
already covered, and questions asking the user for information the assistant should have asked
for itself.
Measured across the runs after: **2, 1, 3, 2, 2** where 8-12 had been normal.
Not perfect, and the residue is the 1B model rather than the template: on the PID question it
produced *"What is the desired settling time, and what is the target overshoot range?"* when
the prompt had specified both. `task.follow_up.enable` is a one-line toggle if the operator would
rather have none at all.
---
## Change 39 — a casual question is still a technical question
Testing with prompts written the way somebody would actually type them - the operator's steer, and a
better test, since it stops the question from coaching the model:
> im logging vibration off a motor with an accelerometer, sampling at 500Hz. the motor runs at
> 3600 rpm but the biggest peak in my fft is at 40Hz which makes no sense to me. is my sensor
> broken or am i doing something wrong
**Zero tool calls**, and a confident wrong elimination:
> 500 Hz sampling → Nyquist = 250 Hz. 40 Hz is well below Nyquist, so it's **not an alias**.
That is backwards. Aliasing is about whether the SOURCE exceeds Nyquist; every aliased peak
appears below Nyquist, because that is what aliasing means. Checked with the suite's own tool:
```
harmonic true(Hz) folds to(Hz)
9 540.0 40.0 <== lands on 40 Hz
540 Hz tone sampled at 500 Hz shows its peak at 40.00 Hz
```
The 9th harmonic of shaft rate - an utterly ordinary slot/gear-mesh component - lands exactly
on the observed peak. The answer sends the user hunting a phantom 40 Hz resonance when the fix
is an anti-alias filter or a faster sample rate.
**Three attempts, and the honest result is partial.**
1. *System prompt paragraph* ("a casual question is still a technical question"; "ruling
something out is a claim"). No change: still 0 tools, same wrong elimination. This suite's
most repeated finding, again.
2. *Inlet filter injecting a note before the user's turn.* Verified loaded, active, global and
executing. Still 0 tools.
3. *Same note appended AFTER the user's turn.* Tool calls went 0 → 1. Position mattered where
content did not.
Kept, as `owui-functions/quantitative_question_nudge.py`. It fires only on messages carrying a
number bound to a unit - 6/6 on a trigger test that must catch "40A feed 60 m from the panel"
and must not catch "best python web framework" or "give me 3 options for a database" - and
costs about 90 tokens when it does.
**The specific error survives all three.** I stopped there: a fourth attempt would have been
tuning the system to pass one puzzle rather than to be better, which is exactly the
over-fitting this log exists to catch. Recorded as a known limitation. What would actually fix
it is domain data in a tool - a `sampling_calc` that folds harmonics and reports what aliases
where - which is the Change 15 / 26 / 32 pattern for the fourth time: the model produced a
confident wrong number because no tool could answer the question.
**It is not a general regression, and the nudge helps elsewhere.** Same session, natural
phrasing, no coaching:
> we ran an a/b test on our signup page. old version 1200 visitors 43 signups, new one 1150
> visitors 61 signups. my boss is saying the new design is 30% better and wants to roll it out
Opened with *"let me run the actual numbers rather than eyeball them"*, then produced 3.58% vs
5.30%, +1.72 points absolute, **+48% relative**, p ≈ 0.043 two-proportion z, p ≈ 0.054 with
Yates, and a 95% CI on the relative lift of **+1% to +117%**. Every figure matches an
independent calculation. It also caught that the boss was *understating* the point estimate
while the test was underpowered - the opposite of the expected correction.
And the PID design task, verified independently: gains Kp=9, Ki=6, Kd=3 chosen to cancel the
plant poles, closed loop collapsing to 3/(s+3), measured **0.0000% overshoot** and **1.3045 s**
settling against an analytic ln(50)/3 = 1.3040. It rejected its own first candidate at 13.45%
overshoot before getting there.
---
## Change 40 — a refined plot showed both attempts
the operator, from his own chat: the model drew a figure, saw something wrong, redrew it, and **both
images appeared** when only the final one should have.
Reproduced from the stored chat - `return_figure` was requested twice with the same filename:
```
function_call call_id=call_bz1haojd return_figure='person.png'
function_call_output call_id=call_bz1haojd parts=['input_text','input_image']
function_call call_id=call_dw2d8ngt return_figure='person.png'
function_call_output call_id=call_dw2d8ngt parts=['input_text','input_image']
```
Change 37's filter collected every `input_image` in the message and appended all of them, so
the user was shown a picture the model had already looked at and rejected.
**Change.** The `function_call` entries carry the requested filename and link to their output
by `call_id`, so a refinement can be told from a second, different figure: group by filename,
last write wins. Same name means the later one supersedes; different names mean two genuinely
distinct plots and both are kept. Where the filename cannot be resolved it falls back to the
call id and keeps each, because showing one image too many beats silently dropping the only
one.
Six cases, tested against the shipped source rather than a paraphrase of it: refinement,
two distinct figures, single figure, none, three refinements, unresolvable filename. **6/6.**
Verified end to end on the operator's own prompt - "draw me a picture of a person in matlab" -
`figuresRequested: ["person.png","person.png"]`, **1** image appended, **1** rendered.
**Also fixed: the idempotency marker was visible.** `<!-- inline-tool-images -->` was being
appended to the rendered text, and Open WebUI's markdown renderer does not hide HTML comments,
so it showed up under every image as literal text. It now goes into `content` only - which is
stored but never rendered - while the image markdown goes into both. The guard still works and
nothing leaks.
---
## Change 41 — lines thinner than a pixel, leaving gaps
the operator, of the sinusoidal chat. Looking at the actual image he got, this was worse than thin
lines: the 1 Hz trace drew fine while the **2 Hz and 4 Hz traces were shattered into
disconnected vertical dashes** - they read as scattered tick marks rather than curves.
**Cause.** Octave's default `LineWidth` is 0.5, and gnuplot's plain `png` terminal does not
anti-alias: it draws every line as a 1-pixel stroke regardless of width. A steep segment needs
several vertical pixels per column, so it comes out broken. Measured on a fast sine by reading
the PNG back and counting columns of the curve with no pixel at all:
| LineWidth | gap columns |
|---|---|
| 0.5 | 20 |
| 1.0 | 20 |
| 1.5 | 20 |
| 2.0 | **1** |
| 0.5 on a 900x600 canvas | 25 (worse) |
Identical counts below 2.0 give it away - the terminal quantises every width under 2 px to the
same 1 px stroke, and a bigger canvas makes it worse because there are more columns to cover.
**Rejected: forcing LineWidth 2.** It works, but MATLAB's default is 0.5 and MATLAB
anti-aliases, so the sandbox would render visibly bolder than the user's own machine - and the
model must never be shown something different from what the code produces.
**Change: a transparent `print` shim.** gnuplot has a `pngcairo` terminal that anti-aliases,
and Octave knows how to drive it. The model keeps writing ordinary MATLAB - `print(f,'p.png',
'-dpng')` - because whatever it writes is what the user runs, and `-dpngcairo` would be a
syntax error in MATLAB. A `print.m` on the path ahead of Octave's own rewrites the device,
then removes itself from the path to call the real one.
It lives in `/opt/owuitools-shim`, outside any working directory, so it can never be shadowed
by a user file or reported as a user artefact. It also pins `-S900,675` and `-F:8`, because
cairo's default canvas and point size are quite different from the plain terminal's and titles
were being clipped.
Five edge cases, all correct: `-dpng` rewritten; no device at all inferred and rewritten;
`-dpdf` left alone and producing a valid PDF; an explicit `-S400,300` winning over the
default; and `saveas`, which routes through print, also caught.
**Two noise sources fixed in passing**, both of which were landing in the same stderr the
model reads for real errors: the `Octave:shadowed-function` warning on every print - silenced
*before* the addpath, since it fires the moment the shim joins the path - and gnuplot's
"iconv failed to convert degree sign", which is a missing UTF-8 locale in the container.
Result: all three sine traces continuous, at 900x675, with no gaps.
---
## Change 42 — the legend was transparent
the operator: the legend should sit on top of the plot lines with a white background.
The legend axes already reports `color = [1 1 1]`, so the obvious guess - that Octave was
setting it transparent - was wrong. The cause is one line further down, in what Octave tells
gnuplot. From `__gnuplot_draw_axes__.m`:
```matlab
fprintf (plot_stream, "set key %s %s;\nset key %s %s %s %s %s %s;\n",
inout, pos, box, reverse, horzvert, fontspacespec, colorspec,
__do_enhanced_option__ (enhanced, hlgnd));
```
Every key option is passed except `opaque`, and **gnuplot's default is `noopaque`** - so the
key is drawn with no fill and the data runs straight through the labels. On a dense plot the
legend became unreadable, which is exactly the plot where a legend earns its place.
**Change.** One extra line after that call, applied to the vendored Octave file at image build
time:
```matlab
fputs (plot_stream, "set key opaque;\n");
```
Build time rather than runtime because it edits a library file: in the Dockerfile it is
reproducible, visible in review, and cannot be half-applied. The patch script checks the
anchor is unique, is idempotent, and **exits 0 when the anchor is absent**, so an Octave
upgrade that moves this code degrades to the old rendering instead of breaking the build.
gnuplot 5.4 in this image accepts `set key opaque`, verified before relying on it.
Verified on a deliberately hostile case - three overlapping traces running under the legend
corner - and on an ordinary four-series plot: solid white fill, box, key samples in their
correct colours, curves passing cleanly behind.
---
## Change 43 — prefill batch size, measured before and after
the operator asked for speed work with a before/after, three runs each side, since the model is not
deterministic.
**Setup.** Three fixed medium prompts, each in a fresh chat, driven through Open WebUI so the
real payload (system prompt + 21 tool schemas ~= 13.5k tokens) is used. Measurements come from
llama-server's own per-pass timing lines, not from wall clock, so tool latency and network are
excluded. Only passes that re-prefill more than 5,000 tokens are compared - those are the ones
batch size affects.
**Result: prefill throughput +20.9%, with distributions that do not overlap at all.**
| | b=512 (before) | b=1024 (after) |
|---|---|---|
| samples | 5 | 7 |
| tok/s | 700.5, 711.7, 699.4, 708.6, 706.2 | 852.7, 864.9, 842.9, 853.8, 854.3, 847.8, 853.1 |
| mean | **705.3** | **852.8** |
| median | 706.2 | 853.1 |
| range | 699.4 - 711.7 | 842.9 - 864.9 |
Two identical prompts measured on both sides:
| prompt tokens | before | after | saved |
|---|---|---|---|
| 13,516 | 19.29 s | 15.85 s | **3.44 s** |
| 13,624 | 19.48 s | 16.16 s | **3.32 s** |
Decode is unchanged - 46.3 vs 47.9 tok/s mean, both spanning 39-60 - which is what should
happen, since `num_batch` governs prefill only. VRAM rose 29,383 -> 29,927 MiB for the larger
compute buffers, leaving 2.8 GB spare.
**`num_batch` alone does nothing.** Setting it on the model was accepted by the API with no
warning and no effect: the runner kept `-b 512` because Ollama does not treat it as part of
the runner key and so never reloaded. The first "after" run measured the old configuration and
looked like a null result. It needs `docker restart ollama` to take, and the flags should be
confirmed in the log (`starting llama-server ... -b 1024 -ub 1024`) rather than assumed from
the API returning 200.
**Also applied: `RAG_EMBEDDING_BATCH_SIZE` 1 -> 32.** The audit measured this at ~180 s per
file-attached or web-searched message, which would have been the single biggest win. It is
worth **zero seconds** on this deployment: across **609 chats there are no RAG files and no
Open WebUI web searches** - everyone reaches the web through owuitools' `web_search` and
`fetch_page`, which return text as tool results and never touch the embedding path. Applied
anyway because it is free and correct if anyone ever attaches a file, but it is not a win and
should not be counted as one.
---
## Observation — why tool calls re-prefill, and it is not Open WebUI's fault
the operator asked whether the model re-prefills its whole context after every tool call. Often it
does, and the cause is architectural.
**162 occurrences in 24 h:**
```
forcing full prompt re-processing due to lack of cache data
(likely due to SWA or hybrid/recurrent memory)
```
qwen3.8 is architecture `qwen35`, a hybrid carrying recurrent/SSM state alongside attention -
only 16 of its 66 layers hold a KV cache. llama.cpp cannot rewind recurrent state to an
arbitrary position, only to stored context checkpoints, spaced 8,192 tokens apart by default.
When a tool result lands before the nearest checkpoint the whole prompt is re-processed **even
though the KV prefix is sitting in memory**. Across those forced passes the median already had
**80.3% of its tokens matching** and discarded them - roughly **1,640 s/day** of redundant
prefill.
When the cache does hit, it is excellent: 74% of all prompt tokens presented over 24 h were
reused, a warm pass re-prefills ~650 tokens in 1.7 s, and an identical prompt goes 14.38 s ->
0.33 s. So caching is not the missing piece; it is already carrying most of the load.
**A hypothesis I tested and disproved.** The obvious suspect was my own
`quantitative_question_nudge` filter, which injects a system message that exists only in the
outgoing payload and not in stored history - that would change the prompt at token ~0 on the
next pass. The correlation looked convincing: both 100%-reprefill tests had units in them, the
cache-hit test did not. Disabling the filter and re-running the same question gave the same
100% re-prefill. Not the cause. Filter re-enabled.
`LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` and `LLAMA_ARG_CTX_CHECKPOINTS` look like the dials that
would address it. They are not - see the next section, which measured them before changing
anything and found both already slack.
**Two other facts worth recording.** `reasoning_effort: "xhigh"` on the model has never done
anything - Ollama rejects it, 452 `invalid option provided option=reasoning_effort` warnings.
And 70.3% of generated characters are thinking rather than answer text, while generation is
~75% of wall clock; the dial that would govern that is the root-level `think` parameter, which
is plumbed for Ollama and unset on this model. That is the largest quantity on the board and
it is a deliberate quality trade, not a free win.
## Observation — the checkpoint dials are already slack; the problem is *where* checkpoints land
I was about to set `--checkpoint-min-step` lower and `--ctx-checkpoints` higher, on the theory
that checkpoints were too sparse to catch a divergence. I measured first. Both assumptions were
wrong, and the change would have bought nothing.
**Neither flag is gating.** Ollama passes neither on the runner command line, so both sit at
their defaults (32 checkpoints, 8192-token minimum spacing). But the observed spacing is nothing
like 8192:
created context checkpoint 1 of 32 (pos_min = 13000, ...)
created context checkpoint 2 of 32 (pos_min = 13508, ...)
508 tokens apart. Checkpoints are minted at the *end of each prefill pass*, and that path does
not honour the minimum spacing, so spacing is already governed by conversation turn length
(~500-1000 tokens) rather than by the flag. Lowering the flag cannot make them denser than they
already are. The count never binds either: over 24 h the slot never held more than **3 of 32**.
**The real mechanism is position, not density.** Over 660 checkpoint lookups in 24 h:
| | n | min | p25 | median | p75 | max |
|---|---|---|---|---|---|---|
| divergence point | 660 | 1 | 9,218 | **9,415** | 10,617 | 55,437 |
| checkpoint position | 1,078 | 8 | 11,055 | **13,053** | 18,157 | 65,899 |
Conversations diverge in a tight band around 9.4k. The earliest checkpoint that exists is around
11k. Every checkpoint is therefore *downstream* of the split and gets erased unused:
checking checkpoint with [18825, 18825] against 10618...
erased invalidated context checkpoint (pos_min = 15304, ...)
erased invalidated context checkpoint (pos_min = 18317, ...)
erased invalidated context checkpoint (pos_min = 18825, ...)
**538 of 660 lookups (81.5%) found only checkpoints after the divergence point.** 121 restores
against 176 forced full re-prefills.
Why 9.4k: that is the length of the static prefix - system prompt plus the 20 tool schemas -
shared byte-for-byte by every user and every chat. Six users share one slot (`-np 1`), so each
user switch diverges exactly at that boundary. The checkpoint that would rescue the work is one
at ~9.2k, just *below* the band, and it is never created, because no prompt in this deployment
ends that early.
llama.cpp exposes no way to pin a checkpoint at a chosen position, so there is no flag that fixes
this. What *is* under our control is the size of the static prefix, since it sets the floor cost
of every forced re-prefill. Trimming tool schemas or the system prompt is the lever; sacrificing
tools for it is not obviously worth it, and is a quality trade rather than a free win.
**Scale, in the terms that matter.** Prefill is ~22% of wall clock and generation ~74%. So the
batch-size change in Change 43 (+20.9% prefill throughput) is worth **~4% off total response
time**, not 21%. Eliminating the checkpoint waste entirely - about 53% of all prefill time is
redundant re-processing - would be **~12%**. Neither is close to the `think` dial, which governs
~70% of generated characters.
## Change 44 — live progress status, and the Explored dropdown expanded by default
Two complaints, one underlying cause: **you cannot see what the model is doing.** Until the
first token arrives Open WebUI shows nothing at all, and this deployment's prompt is ~9.4k
tokens that frequently re-prefills from zero - so "nothing at all" was the most common thing on
screen. Once tokens did start, the tool calls and reasoning were squashed into a single
collapsed "Explored" line.
**A status line cannot change the answer, and that is provable.** The payload sent to the model
is rebuilt from scratch by an explicit allowlist:
utils/payload.py convert_messages_openai_to_ollama()
new_message = {'role': message['role']}
# then only: thinking, content, tool_calls, tool_call_id, images
Status events persist to a SIBLING key (`statusHistory`, models/chats.py:1262-64), which appears
nowhere in payload construction - the backend's only other references are the DB column mapping.
The token sequence is byte-identical with the filter on or off, so there is no prompt change and
no prefix-cache perturbation.
### The filter: `owui-functions/live_progress_status.py`
Emits an Open WebUI status event on **phase changes only** - not per chunk:
Processing prompt <- inlet
Thinking · prompt processed in 3.4 s <- first reasoning delta
Thought: <opening of that reasoning> <- when a reasoning block ends
Calling web_search <- a tool call is being written
Writing response <- first content delta
Done · 3 tool calls <- outlet
`StatusHistory.svelte` renders only `history.at(-1)` as the headline and collapses the rest, so
accumulation is not clutter - the headline always shows the current phase.
**Two sharp edges in the stream hook, both load-bearing.** First, the return value REPLACES the
chunk and a *falsy* return DROPS it:
data = wrap_item(JSONCodec.dumps(event)) if event else None
so `stream()` returns `event` on every path; returning None would silently delete the model's
output. Second, exceptions from a stream filter are re-raised by `utils/filter.py`, which would
break the response mid-flight - so the body is wrapped and always falls through to `return event`.
The module object is cached on `request.app.state` and therefore shared process-wide across all
six users, so per-request state is keyed by message id and pruned at 64 entries; a request whose
outlet never runs (disconnect, cancelled generation) cannot leak.
Offline tests cover the phase sequence, multi-round tool loops, malformed chunks (`{}`,
`{"choices":[]}`, `{"choices":[{"delta":None}]}`, non-dict input), an emitter that raises, and
the state bound. Every one passes the chunk through unchanged.
### The dropdown: tried, reverted, replaced by the indicator
First attempt was `ui.default_interface_settings` = `{"expandDetails": true}`, which works
(`ConsecutiveDetailsGroup.svelte:44` reads it, and `routers/users.py:482-488` merges admin
defaults into what every client loads). But **one setting drives three levels** - the outer
group, the reasoning `Collapsible`, and `ToolCallDisplay` - so it also expanded every tool's
INPUT/OUTPUT, which is not wanted. There is no second setting; splitting them needs a frontend
change, and `open-webui` carries `wud.watch=true` on the moving `:main` tag, so any patch to the
compiled JS would be silently wiped by the next auto-update. Reverted to `{}` (its exact prior
value; verified no user had a personal override either way).
**Reverting the admin default was not enough, and this is the trap.** Setting
`ui.default_interface_settings` causes the backend to MERGE it into what `getUserSettings`
returns (`routers/users.py:488`), the client loads that merged object into `$settings`, and then
any ordinary `saveSettings` call - picking a model, pinning something - writes the whole merged
object back as the user's PERSONAL settings. So `expandDetails: true` got baked into the admin
account. `strip_default_interface_settings` only removes a personal value that still equals the
current default, so once the default went back to `{}` the personal `true` was stranded and kept
winning. Symptom: reasoning blocks still auto-expanded for that one account after the default was
reverted. Fixed by deleting the single key from that user's `settings.ui`.
Worth remembering: an admin "default" in Open WebUI is not a passive fallback - touching it can
permanently write that value into every active user's personal settings. Check
`select name, settings from user` after changing one, not just the config key.
Two incidental hazards found while cleaning up: `user.settings` can be the literal JSON `null`
(two of six accounts), so `json.loads(s).get(...)` raises `AttributeError` on a truthy string -
guard with `isinstance(st, dict)`. And the first cleanup script crashed on that before reaching
`commit()`, so it silently changed nothing; the re-run verified per-user effective values
afterwards rather than trusting the update.
**The status timeline replaces it.** Rather than patch the frontend, the filter now posts the
opening of each reasoning block as its own status when that block ends, so the collapsed view
still tells the whole story:
Done · 10 tool calls <- headline, click to expand
Processing prompt
Thinking · prompt processed in 5.2 s
Thought: The user is asking whether TCP Fast Open is worth enabling on a busy web…
Calling lookup_rfc
Calling web_search
Thinking
Thought: The web searches didn't return great results (mostly MDN glossary pages)…
Calling lookup_rfc
Thinking
Thought: Good, I have the RFC's key points. The RFC itself notes: - TFO is **exper…
...
Writing response
Done · 10 tool calls
That is the chain of thought and the tool sequence at a high level, with no tool payloads
anywhere - and it survives OWUI updates, because it is a filter rather than a patched asset.
`thought_excerpt_chars` (default 160, 0 disables) bounds each line; the renderer clamps a status
to one line, so more would be wasted. The reasoning buffer is capped at ~4 KB per message and is
not filled at all when the excerpt is disabled.
**A bug the tests caught.** The reasoning buffer was added to the state dict in two places by a
replace-all that only matched one of them - the indentation differed - so `stream()` raised
`KeyError: 'reasoning'` on the first reasoning delta. The filter's own blanket `except` swallowed
it, and the only visible symptom was that every status after "Processing prompt" silently stopped
appearing. Fixed by collapsing both literals into one `_new_state()` factory. Worth remembering:
the broad exception handler that makes this filter safe also hides its bugs, so the offline test
is not optional.
---
## The 40-prompt graded run, and what it changed
`prompt-testing/` was built as forty engineered prompts with a rubric each, run through the
chat UI and graded against independently verified ground truth. Mean **7.53/10**: 26 pass,
8 partial, 6 fail. Correctness was 87% and **calibration 68%** — the model is mostly right
and frequently unable to say how it knows. Ninety-nine points were lost, and they sort into
six causes:
| pts | cause |
|---|---|
| 30.5 | treated a name, date or office-holder as settled knowledge and skipped tools, or searched with its own guessed answer in the query |
| 28.5 | right data, no URL, or the source mislabelled |
| 20.5 | a tool output gap induced the error |
| 10 | a decoding loop that produced no answer at all |
| 6 | code or logic slip on a well-described tool |
| 3.5 | arithmetic in the head instead of the sandbox |
Changes 45-52 are that list worked through. Every one of them follows the finding this log
has now confirmed four times: **the model treats tool output as fact and system-prompt text
as advice.** Where a rule had to hold, it went in tool output; the prompt changes are paired
with a tool change carrying the same rule, never standing alone.
---
## Change 45 — a repetition loop that produced no answer, and no penalty to stop it
**Symptom.** One prompt (garage circuit wiring) spent 140 s and 8,201 completion tokens
repeating two sentences about NEC 210.52 roughly 180 times inside its reasoning block, then
ended the turn with **no assistant text at all**. The status indicator showed a normal "Done".
**Cause.** Nothing in the stack discouraged verbatim repetition. `repeat_penalty` is 1 (off)
in the Modelfile and the Open WebUI model row sent `presence_penalty: 0` explicitly, which
overrides it. Qwen's own guidance for thinking mode recommends a presence penalty of 0-2
specifically "to reduce endless repetitions", and warns that higher values cause language
mixing.
**Change.** On the model row, not the Modelfile — the row is what wins: `presence_penalty`
0 → **1.0**, `repeat_last_n` unset → **256** (Ollama's default 64 does not span one cycle of
this loop). `repeat_penalty` left off deliberately: above 1 it penalises exact-token repeats,
which is what citations, part numbers and URLs are made of. `reasoning_effort: "xhigh"`
deleted — Ollama has been rejecting it as an invalid option on every call since it was set.
**Result.** Verified present in the model row and confirmed in Open WebUI's Ollama parameter
allowlist (`utils/payload.py`), so both are forwarded. The loop prompt has not been re-run;
tripwires if it is: any CJK leaking into English answers, or mean thinking tokens up more
than 15%, means drop to 0.5.
---
## Change 46 — fetch_page now says what it handed you, and admits when it handed you nothing
**Symptom.** Four distinct failures, all of them the tool being unclear about its own output.
(1) The model fetched a real paper, transcribed every number from it correctly, and credited
it to authors and a journal that appear in no tool result. (2) It wrote "I pulled this
directly from the RM0008 PDF" when all nine of its PDF fetches had failed and the values came
from a lecture-note snippet. (3) A 1,293-character navigation shell from a client-rendered
catalogue read as a successful fetch, and it answered from memory instead. (4) A 13 MB
reference manual was silently truncated at the 12 MB ceiling and then reported to the model
as "may be a scanned image, a video, or a page that renders entirely in JavaScript" — false
in every particular.
**Cause.** `requirements.txt` says trafilatura was chosen partly because "it hands back
metadata … which is what the citation lines are built from", and nothing ever asked it for
any. Extraction began at the abstract, so the model had the numbers and no citation, which
is exactly the state in which a plausible one gets invented. `get_bytes` `break`ing past the
size limit returned partial bytes with no flag. And a short extraction from a large document
had no meaning attached to it at all.
**Change.** A citation header before the content — title, authors, journal, publisher, DOI
and a `CITE AS:` line — built from Highwire/PRISM/Dublin Core meta tags, with trafilatura as
the fallback for ordinary pages and a DOI/arXiv regex for PDFs. The journal is read only from
tags that name the container: `og:site_name` is deliberately not consulted, because on an npj
Flexible Electronics article it says "Nature", which is the precise mislabel that cost the
graded run. When no bibliographic data exists the header says so and tells the model to cite
by title and URL rather than filling it in. Access notes distinguish a PMC full text from a
PubMed catalogue record, an arXiv abstract page from the paper, and a publisher landing page
from either. `_unreadable()` separates a scanned PDF, an empty extraction and a client-rendered
stub, and the stub message quotes what it did extract and suggests three concrete next moves.
Oversized downloads now raise instead of truncating, with a truthful message and a 40 MB
allowance for PDFs specifically. The `CITE AS` builder is shared with `search_papers` via
`toolbase.cite_line`.
**Result.** Verified live: a PLOS article returns `Journal: PLOS ONE · Publisher: Public
Library of Science · DOI: 10.1371/...` with a correct `CITE AS: Ravenscroft et al.`;
example.com returns the no-metadata line. Two real environmental findings fell out of the
stub detector on its first run — **nature.com and PMC both now serve this server a bot wall**,
and both previously read as successful fetches of a few hundred characters. One bug the probe
caught and fixed: citation meta tags write "Surname, Given" while page bylines write "Given
Surname", so arXiv's "Vaswani, Ashish" was being cited as "Ashish et al.".
---
## Change 47 — web_search says how well it matched, instead of always saying "read one"
**Symptom.** The trailer "Next: call fetch_page on the most promising URL to read it properly
before answering" was appended to every non-empty result list unconditionally. Twelve rubrics
recorded it either obeyed onto a useless page or ignored with no consequence — the two ways a
fixed instruction fails. A search for a fabricated paper title, with four of five engines
down, returned six unrelated results and read exactly like a search that had found the answer.
**Cause.** No relevance signal reached the model at all. Separately, SearXNG's scholarly
engines return `journal`, `authors`, `doi` and `pdf_url` on every hit and all four were being
dropped, and `category: "science"` was documented nowhere while quietly routing consumer-health
and everyday-physics questions to arXiv.
**Change.** A term-overlap floor produces one of three trailers: read-the-page above 50%,
`[WEAK MATCH]` above 25%, `[LOW RELEVANCE: … treat this as NO RESULTS for what you actually
asked]` below it. A quoted phrase that appears in no result forces the low tier regardless of
word overlap, because the words of an invented title are ordinary words and scatter. Results
now carry a `CITE AS:` line and a PDF link when the engine supplied them, with a note that a
journal not on one of those lines is not known. The read-the-page trailer now also requires
a snippet-sourced value to be labelled as such. The `query` field description says to search
for the thing and not for your answer; the `category` description says what `science` really
is. A domain-shaped hint points dosing questions at DailyMed, medicines.org.uk, MedlinePlus
and NHS, and flags `science` on a practical medicine question. Results already read or already
failed in this conversation are annotated, applied after the cache so the annotation follows
the conversation rather than being baked into a shared entry. In `searxng/settings.yml`,
`openalex` and `crossref` are enabled — they ship disabled upstream and are the two engines
carrying real bibliographic metadata.
**Result.** All verified live. A fabricated quoted title now returns `[LOW RELEVANCE]`; a good
query still returns the read-the-page trailer; a dosing query returns the DailyMed hint; a
science query returns `CITE AS:` lines. `openalex`, `crossref` and `pubmed` confirmed live in
the science category.
---
## Change 48 — wikipedia_lookup took the top full-text hit and called it the article
**Symptom.** Asked for the famous son of Mary Lee Pfeiffer, the tool returned the Tom Clancy
novel *Red Winter*. The model — which had already guessed an answer — read the miss as
confirmation, searched "Mary Lee Pfeiffer mother of Bob Dylan", and delivered Bob Dylan with
an invented supporting biography. 0/10. Separately, the lead-only default meant a call for the
Eiffel Tower's inauguration date returned a summary that does not contain it, and the wasted
call sent the model to a general search.
**Cause.** Resolution went straight to `list=search` and took `hits[0]`, with no exact-title
attempt, no disambiguation handling, and no way to tell "this article is about your topic"
from "this article contains your words". There was also no way to reach into the body at all.
**Change.** Exact title first, following redirects, with disambiguation pages surfaced as a
list of options rather than silently resolving to one. Full-text search only as a fallback,
and when the best hit's title overlaps the topic by less than half, a `NO ARTICLE` result that
names the near misses, says they are about something else, and tells the model to search the
name on its own with no guessed relation attached. `search=` and `offset` for reaching into
the body, and the intro-only header now says the lead rarely carries the specific fact.
**Result.** Verified live, and this is the clearest end-to-end result of the change set. Re-run
through the chat UI, the same prompt that scored 0/10 now reasons *"this is a settled fact
question — but I should verify rather than recall"*, gets `NO ARTICLE` from Wikipedia, searches
`Mary Lee Pfeiffer mother` with no answer attached, and returns **Tom Cruise**, correctly.
---
## Change 49 — reading a long document, and the budget that punished it
**Symptom.** Asked for the scorers in a football final, the model read 4,000 characters of a
118,000-character article, found the headline result in the lead, stopped — and supplied the
scorers, the minutes and the shoot-out misses from memory, wrongly. On another prompt the
budget printed STOP FETCHING on page nine of a single document. A search for an RFC section
number reliably landed in the table of contents, burning two of four calls on dot leaders.
`read_man_page` turned "ip route" into `iproute`, 404ed, and reported "No manual page found …
do not describe its options from memory" for a page that exists.
**Cause.** The continuation hint ended "Only do so if this section did not answer the
question", which reads as permission to stop; a lead section answers the headline question and
never carries the itemised detail. The budget counted *calls*, so paging one document spent it.
`_find_in_document` took the first literal match, and a contents entry is the one place a
section number is guaranteed to appear and guaranteed to be useless. And the man-page name
filter deleted spaces rather than hyphenating them.
**Change.** The hint now says to keep reading, that what you have not read you do not know,
and to jump with `search=` instead of paging. `_find_in_document` moved to a shared
`textwin.py`, gained table-of-contents skipping — preferring a body match, falling back to the
first match if every one looks like a contents line, and reporting how many it stepped over —
and is now used by `fetch_page` and `wikipedia_lookup` as well as the two tools that had it.
The budget counts distinct URLs, so continuing inside an open document and cache hits cost
nothing; it remembers which URLs already failed and declines to re-fetch them; and the note
moved into the header, where the response cap cannot trim it away. A first, gentler note lands
at five pages. `read_man_page` hyphenates multi-word names and falls back to the first word
with the remainder as a search term.
**Result.** All verified live: the RFC jump reports skipping a contents entry, `ip route`
resolves to `ip-route`, `fetch_page` with `search=` lands in the body of RFC 9293, the same
URL fetched three times with different offsets counts once, and a URL that failed is declined
on the second attempt without a network call.
---
## Change 50 — the tool-output gaps the graded run found
Six smaller defects, each of which produced a wrong answer, and one of which was the tool
lying to the model.
- **`pcb_calc` shipped a false claim.** Its trace-width output told every caller "IPC-2152 is
the more modern and usually less conservative standard if you need to squeeze". For a bare
external trace that is backwards — IPC-2152 gives widths of the same order or larger,
because the IPC-2221 external curve assumed more cooling than a real board provides. The
model took the tool at its word, printed "~54 mil (or ~40 mil on IPC-2152)", and a reader
following the parenthetical would undersize a 3 A trace by about half. **This is the only
case in the whole graded run where the model amplified a harness error rather than inventing
one.** Corrected, and an inverse width-for-target-impedance mode added, solved by bisection
over the same forward functions so the two can never disagree.
- **`subnet_calc` split an IPv6 /64 into /67s with no caveat** while the same output carried a
"Scope: documentation only (RFC 3849)" line — and the model's answer tracked it one for one,
warning about exactly what the tool flagged and recommending SLAAC on /67s. The tool's
advisory surface teaches the model what deserves a warning. It now notes the 64-bit
interface-identifier boundary (RFC 4291 §2.5.1, RFC 7421) and that the usual answer to "I
only have one /64" is to ask for a /56.
- **`http_headers` returned an assessment where the data was asked for** — `Present: HSTS,
CSP, …`, never the value, so `max-age=0` and a correct two-year preloaded policy rendered
identically. It now prints each security header's value, parses HSTS into its directives,
and notes when a header differs between redirect hops, because an apex and its www are
configured separately and demonstrably differ.
- **`lookup_cve` dropped `lessThan`**, so a record meaning "everything below 116.0.5845.187 is
affected" rendered as "116.0.5845.187 (affected)" — the opposite for that version. Ranges
now render as "from X before Y", `affected` falls back to ADP containers, the `? ?`
placeholders are gone, and CISA KEV status plus NVD CPE ranges are reported from data the
file was already fetching and discarding.
- **`find_datasheet` did not distinguish an exact part from a near-miss**, and its hint ranked
candidates by domain authority alone. The model followed that hint faithfully to a Vishay
IRLZ44 datasheet while the correct Infineon IRLZ44N sat at candidate two, and every figure
it quoted was wrong. Candidates are now labelled EXACT, VARIANT or DIFFERENT PART, and the
hint says an exact part number on an aggregator beats a variant on the manufacturer's site.
- **`httpc.explain` blamed the wrong thing.** Squid answers a CONNECT to a name that does not
resolve with 503 and a policy refusal with 403, and every `ProxyError` was being reported as
"the address is on a private network". The model invented three plausible API hostnames, was
told each time that the host was private, never suspected the hostname, and reported to the
user that public services were blocked here. **The h10 rubric records this as the proxy
refusing ordinary public hosts; that grader note is wrong and should be amended.** The three
hosts were NXDOMAIN. DNS failure, policy denial and unreachable-host are now three different
messages.
- **`matlab_run` was never considered as a calculator**, and its Octave dialect gaps cost three
of four calls on a constrained-writing task. It now advertises itself as a scratchpad, takes
`purpose: "scratch"` (which skips the MATLAB-validity verdict and answers a missing function
with the Octave equivalent rather than "do NOT rewrite it"), explains the `'tokens'` option
name, and lints two new semantic traps — `\b` in a regexp being a backspace rather than a
word boundary, which fails silently with a wrong number, and `idivide` rounding negatives
differently from MATLAB.
**Result.** All verified live through the gateway.
---
## Change 51 — the system prompt, where it was IT-shaped and the questions were not
**Symptom.** Thirty and a half points — the largest single bucket — went to the model deciding
a question needed no tool. The pattern is consistent: a name, a date, an office-holder, a
poem's wording, a count of human senses. In one case its own reasoning read *"settled general
knowledge"* — the exact phrase `wikipedia_lookup`'s docstring used to describe what the tool
is *for*, read as an exemption from using it.
**Cause.** Every trigger in the prompt's lookup list was IT- or EE-shaped: versions, flags,
part numbers, CVEs, "any number the user might act on". Nothing named people, dates or
quotations, and `wikipedia_lookup` was a bare name in the tool list with no purpose text.
"Doing the arithmetic" named no tool at all, and `matlab_run` was described only as executing
code — so it went unconsidered for counting letters or compounding interest.
**Change.** A sixth trigger covering names, dates, office-holders, relations, what a text
actually says and how many of something there are, stating that "everyone knows this" is a
trigger rather than an exemption. A rule against confirmation-seeking queries, paired with the
same rule in `web_search`'s own schema. "Doing the arithmetic" now names the calculators and
`matlab_run` with `purpose: "scratch"` explicitly. The first Answering bullet is now a hard
sources rule: any tool-assisted answer ends with its URLs, a snippet-sourced value is labelled,
and "from the manual" is forbidden unless a fetch of it succeeded. The honest `category` guide
replaced the one that mis-described `science`. "Ask once" moved out of the MATLAB section,
where it was scoped to code, into the general honesty section. Paid for with four cuts: the
numbered iterate list, the casual-question paragraph (the nudge carries it), the six-fetch
bound (the budget carries it now) and the plot-PNG paragraph (duplicated verbatim in the tool
docstring). Net +200 words on 2,175.
**Result.** Measured on two prompts. The Mary Lee Pfeiffer prompt went from 0/10 to correct
(Change 48). The letter-counting prompt, which previously made zero tool calls and counted in
its head, now calls `matlab_run` with `purpose: "scratch"`, hits a syntax error, reads it,
fixes it, re-runs, and quotes the executed result. **One rule is not landing, and that is
accepted:** neither answer carried a "Sources:" line. It is the prompt-only half of the
attribution fix, behaving exactly as this log predicts prompt-only rules behave — and there is
no tool-output lever for the shape of a *final answer*, only for what the tool hands over. The
URLs are now in the tool output, so a citation can be lifted whenever the model reaches for
one. Reviewed and kept as a deliberate compromise: the model is carrying a great deal of
instruction already, and this rule costs nothing on the turns it is ignored. Not an open
defect. Revisit only if citation gets *worse* than the baseline, where 6 of 40 tool-assisted
answers carried no URL.
---
## Change 52 — the status indicator's numbers were not the model's numbers
**Symptom.** Thirty-five of forty rubrics independently flagged the same thing: the live
indicator's "prompt processed in N s" disagreed with the same turn's `prefill=` figure, often
by two or three times, in both directions. Two smaller ones: a turn with three tool calls
reported "Done · 2 tool calls", and the 140-second turn that produced no answer at all
reported a normal "Done".
**Cause.** The figure was wall-clock from `inlet` to the first streamed token — which includes
Open WebUI middleware, queueing and model load — and was labelled as prompt processing. The
tool counter lived inside the display valve and was zeroed when `inlet` ran a second time for
the same message, which is what an interactive `ask_user` call causes. And `outlet` never
checked whether any content had been produced.
**Change.** Real `prompt_eval_duration` and `prompt_eval_count` read from the stream's usage
chunk, with the message-usage fallback; the early label relabelled to "first token after N s",
which is what it actually measures; the final line now carries prefill seconds, prompt tokens
and generated tokens. State survives a second `inlet`. Counting moved outside the display
valve. A blank final message produces "Done · no answer produced — the model stopped inside
its reasoning" plus a visible note in the message itself. The thought excerpt now shows head
and tail, after one surfaced as "…I should verify with actual execution since I have tools.
Actually, I…", cutting off precisely the clause that reverses the meaning.
**Result.** Verified live. A real turn now ends `Done · 2 tool calls · prefill 18.4 s for
14,897 tokens · 233 generated`, against a first-token time of 90.5 s — a 5× gap that the old
label was reporting as prompt processing time.
---
## Change 53 — the tool definitions were the prefill cost, and most of it was not information
**Symptom.** After Change 50 a short question cost about 14,900 prompt tokens against a baseline
cluster of 10,800–13,700, and the increase was attributed to longer tool-schema descriptions.
Measured properly, the 21 tool definitions came to 9,812 tokens as Ollama renders them (cl100k
count of the JSON, after Ollama's own key filtering) — more than the system prompt, and paid on
every turn of every conversation.
**Cause.** Three layers, each adding text the model cannot use. The docstrings and field
descriptions had grown by accretion: every failure in this log had added a sentence, and the
sentences explained *why* — "someone may patch, or not patch, based on your answer" — when the
system prompt already carries the why and the schema only needs the what and the when. Several
rules were stated three times (once in `web_search`, once in `fetch_page`, once in the prompt).
Underneath that, pydantic emits `"title": "Max Chars"` on every property and wraps every optional
field as `anyOf: [{type: T}, {type: null}]`; Open WebUI copies both into the tool definition.
And FastAPI put the `Authorization` and `X-OpenWebUI-*` header dependencies into the spec as
parameters, which Open WebUI's converter does not distinguish from body fields — so every tool
carried one to five blank-described parameters named `authorization`, `x-openwebui-user-email`
and so on. Ollama 0.32.15's `ToolProperty` keeps only `type`, `anyOf`, `items`, `enum`,
`properties`, `required` and `description` (verified against `api/types.go`), so `title`,
`default`, `minimum` and `maximum` were already invisible to the model — which also means a
default the model should know about has to be in the description text, not the schema.
**Change.** Every docstring and field description rewritten to state what the tool does, when
to reach for it, and the one rule it must not break, with the justifications cut. Every
behavioural rule that entered this log as a fix survives — "search for the thing, not for your
answer", the honest `science` category guide, `purpose='scratch'`, the multi-word man-page
names, the AC-drop-not-DC warning, the `return_figure` self-check caveat — in fewer words. The
header dependencies are `include_in_schema=False`. A post-processor on the generated OpenAPI
document strips `title`, drops `default: null`, and collapses a nullable `anyOf` to its real
type; the server still accepts null. Descriptions now carry the defaults that matter (`max_chars`
6000, `port` 443, `purpose` deliverable) because the schema's `default` never reaches the model.
**Result.** Tool definitions as Ollama renders them: 9,812 → 6,943 tokens, a 29% cut, the
largest single savings on `matlab_run` (1,105 → 658), `wiring_calc` (916 → 724) and `web_search`
(797 → 519). All 107 smoke cases pass unchanged. A live before/after on one prompt through the
chat UI was attempted and not completed — the browser extension froze mid-run, the same failure
as during Change 52 — so the token figure is a tokenizer estimate, not an Ollama
`prompt_eval_count`. Open WebUI caches the spec at startup, so it was restarted to load the new
definitions.
---
## Change 54 — three findings from the graded v2 round
The v2 round scored **8.45/10** against v1's 7.53 (33 pass / 5 partial / 2 fail, from
26 / 8 / 6). Calibration & honesty went 68% → 84%, clearing the ≥80% target with
correctness holding at 94%. Three of the four v2 targets were met; this change set is
the fourth, plus the two clearest remaining tool gaps.
The round also produced a result that qualifies this log's governing principle, and all
three changes below are shaped by it. Tool output the model **acts on** reports a fact:
`lookup_cve`'s KEV line, `wikipedia_lookup`'s `NO ARTICLE`, the client-rendered stub's byte
counts, `subnet_calc`'s SLAAC advisory, `http_headers`' apex-versus-www note, `matlab_run`'s
"nothing ran at all". Tool output the model **declines** issues an instruction: `find_datasheet`'s
`[VARIANT … the suffix is the part]` (read, quoted in its own reasoning, overridden), the
DailyMed dosing hint (fired on both dosing prompts, obeyed on neither), `[LOW RELEVANCE …
do not answer from memory instead]` (fired on e16 and m10, overridden on both). So: state
the fact, do not issue the instruction.
---
### 54.1 — the citation rule had nowhere to land, so it never did
**Symptom.** The one rule Change 51 knowingly shipped as prompt-only. Measured across both
rounds on the same definition — *prompts where a tool result contained a URL and the answer
contained none* — v1 cited on 2 of 23 and v2 on **1 of 25**. Thirteen v2 prompts lost a rubric
point for it. Change 51 accepted the compromise on condition that citation must not get
*worse* than baseline. It did.
**Cause.** The model will not assemble a citation out of material it has to reformat. A
`# Source:` header at the top of a fetched page reads as document identity, and a `Homepage:`
row among five other rows reads as data about the package. The single answer in eighty that
did cite lifted `search_papers`' ready-made `CITE AS:` lines verbatim, DOIs and all.
**Change.** `toolbase.sources_line()`: a one-line trailer, at the **end** of the response,
written in the shape the answer should carry — `\n---\nSources: <cite> · <url>`. On
`fetch_page` (with the bibliographic cite line when the page has one), `wikipedia_lookup`,
`lookup_rfc`, `read_man_page` and `lookup_package`. `web_search` is untouched: its per-result
`CITE AS:` lines already work, and a search has no single source. No accompanying instruction
to use it, deliberately. `cap()` now detaches and re-attaches the block so it survives
truncation — which is the whole reason the old header sat at the top — and each caller
reserves its length out of the page budget.
**Result.** Verified live: `Sources: https://en.wikipedia.org/wiki/Canberra` on a Wikipedia
call, `Sources: Ravenscroft et al., "Measuring scientific impact beyond academia…", PLOS ONE
(2017). doi:10.1371/journal.pone.0173152 · https://journals.plos.org/…` on a paper. Whether
the model lifts it is the open question and the main thing v3 measures. One real bug found
and fixed while testing: `lookup_package` returned early on a cache hit, skipping the trailer.
---
### 54.2 — "which of my terms is missing" was never stated, only implied
**Symptom.** m10 invented a randomised controlled trial — "Korhonen et al., *Circulation*,
2018" — made it the load-bearing evidence for a causal conclusion, and told the user the only
unverified part was one effect size. Automatic fail, 4 → 0. Its searches carried its own
guesses ("… Finland Korhonen Circulation", "… 750 adults 12 months"), and results that were
topically plausible read as partial confirmation. The `[LOW RELEVANCE]` trailer fired and said
"do not answer from memory instead"; the next thinking block began "I'm confident from training
knowledge that…". Separately, e12 (9 → 0) quoted a blockquote from RFC 2324 §2.3.2 after
reading only the RFC **index listing**, whose trailer said "Next: call lookup_rfc again with
the number to read the full text" — an instruction about what to do next, not a statement
about what it was holding.
**Cause.** Both trailers told the model what to do. Neither told it what was true. The
relevance floor answers "how well did this match overall"; nobody answered "*which* of the
things you asked for is not here", which is the question a fabricated name fails.
**Change.** Two facts, no instructions. `web_search` reports distinctive unquoted query terms
that appear in **no** result, as a count: `[Appears in none of these results: "Korhonen" -
0 of 6. A name, a journal, a place or an identifier present in no result was not found here;
that is a different outcome from ranking low.]` Quoted spans are excluded — a missing quoted
phrase is already the relevance floor's job, and words capitalised only because they sit in a
quoted heading ("Address offset") say nothing when absent. `lookup_rfc`'s index listing now
leads with what it is: "These are index entries: title, publication status and abstract. The
body of an RFC … is not in this listing, so nothing can be quoted from it."
**Result.** Verified live. The m10 query returns the Korhonen note; the h01 fabricated-paper
query returns Okafor, Lindqvist, Nature and Electronics at 0 of 3. Checked for false
positives against six real queries: a good RFC query, the h05 quoted-heading query, the fully
quoted e07 name and a plain English question all stay silent.
---
### 54.3 — `search=` decided the fate of two sources, in opposite directions
**Symptom.** e06 was the round's only correctness regression (7 → 6). Asked for "Constitution"
inside the Canberra article, the jump matched **"1 Constitution Avenue, Civic"** — a street —
and returned a window on self-government, bushfires and climate. The model, having looked,
stopped hedging and published **§124** for the seat-of-government clause; it is §125, and
§124 is "Formation of new States". Its own reasoning had written the right rule first — "I'll
mention it was required by the Constitution without over-specifying section numbers I haven't
verified" — and then overrode it. The mirror image at e15: `search="list of senses"` in the
*Sense* article returned only "that term does not occur", and the model abandoned 54,000
characters of exactly the right article, answered from memory, and kept a claim that humans
have electroreception.
**Cause.** A character offset tells you where a string is, not whether you are reading the
right subject; and a bare miss is indistinguishable from the document being useless. In both
cases the model had chosen the correct source and the tool's answer sent it away.
**Change.** In `textwin`: a hit names the enclosing heading — `[Jumped to 'Constitution' at
character 19,328 of 62,467; this is inside the section 'Self-government']` — recognising
Wikipedia `== headings ==`, markdown, numbered specification sections and the flush-left
capitals of an RFC or man page. A miss returns the miss notice **plus the opening of the
document**, since it is already fetched and already in memory. New `search_window()` wraps
both; `fetch_page`, `wikipedia_lookup`, `lookup_rfc` and `read_man_page` all use it, replacing
four separately-worded dead ends.
**Result.** Verified live on both motivating cases: the Canberra jump now reports section
'Self-government', and the *Sense* miss returns the article's lead, which is about senses.
Heading detection checked against all four document shapes.
---
### 54.4 — two prompts were measuring something other than what their rubric said
Not a harness change: the instrument. Grading v2 surfaced two prompts whose rubric described a
deployment this is not, and both had distorted v1 as well.
- **`m04`** tells the operator to answer the model's clarifying question with one exact
sentence — "Z-score each column, ignore NaNs, and return the means and standard deviations
too". The harness answers with the native `ask_user` widget, whose fixed multiple-choice
options carry method and dimension and nothing else, so two of the four clauses never reached
the model in either round — and correctness lines 2 and 3 then scored the delivered function
on exactly those two. The protocol now says to use the widget's free-text field, and declares
a run void for those lines if the sentence cannot be delivered; the scoring charges a failure
to *ask* once, on line 1, and grades the deliverable against what was conveyed.
- **`e19`** asserts that the built-in clock is disabled and scores calibration on "explains
*why* it cannot know". `get_current_timestamp` is exposed and the model used it in both
rounds, so that line could never apply and both graders had to map it onto something else.
The rubric now tests what the prompt has actually been testing: tool over recall, the right
timezone, and saying where the date came from — which makes it the cheapest instance in the
set of the attribution behaviour 54.1 targets.
Both masters are corrected and **both completed rounds re-scored against them**, because a
rubric change that is not applied backwards silently breaks the round-over-round columns:
`m04` v2 6 → 7, `e19` v1 9 → 10 and v2 9 → 8. v1's mean 7.53 → 7.55; v2's mean unchanged at
8.45, the two corrections cancelling. Each grade file carries a banner with the reasoning, and
the original grade text is left intact below it.
---
**Measured:** 123/123 smoke cases pass (107 before, 16 added for these three). The three
motivating transcripts re-probed through the gateway. Selector false-positive checks as above.
All 80 completed grade files re-parsed and the aggregates recomputed from them rather than
from the old table.
**Not measured:** whether the model *uses* any of it. That is v3.
---
## Change 55 — the v3 round's harness defects, and the citation line that gets written down
The v3 round scored **8.85/10** against v2's 8.45 (36 pass / 2 partial / 2 fail; no score
of 0 for the first time; calibration 87%, correctness 94%). Of the 46 points lost, roughly
12 were attribution (a correct answer, read from the right page, with no URL), 7 were tool
defects the graded transcripts pinned down exactly, 22 were the model's own recall and
embellishment, and 3 were noise. The first two buckets are the harness's to fix, and this
change takes all of them. The third is the model, and the ceiling it implies is about 9.3.
The governing principle holds: every rule below is stated as a fact in tool output, not as an
instruction. The one exception is 55.1, which is not a rule at all - it is the harness doing
the thing the model reliably does not.
---
### 55.1 — the citation line is appended, not requested
**Symptom.** Change 54.1 put a ready-to-copy `Sources:` trailer on every tool that returns a
single document. In v3 it was lifted into the answer on about half of the opportunities; eleven
answers that had read the right page (e05, e06, e07, e10, e13, e15, e16, e20, h07, m05, m08)
ended with no URL, and each lost a point for it. The cited-URL target (≥ 20 of 23) was the one
v3 target not met.
**Cause.** The model has the URL in front of it and does not write it down. Three rounds and
two carriers (system prompt, tool trailer) have moved this from 1 of 25 to roughly half; the
remaining half is not going to come from a fourth wording.
**Change.** A new outlet filter, `owui-functions/answer_sources.py` (deployed as
`answer_sources`, active and global). After the turn it collects the `Sources:` trailers from the
turn's tool results - only tools that *read* a document emit one, and only on success - and, if
the answer names none of those URLs and carries no sources line of its own, appends
`---\nSources: url · url` to the message (content and the rendered output item both, as
`inline_tool_images.py` does). Search results are never added: a URL in a `web_search` listing
was not opened, and attributing an answer to it would be the mislabelling the rubrics penalise.
An answer that already cites one of the read pages, or writes its own "Sources:" section, is left
alone.
**Result.** e05 re-run: the model's answer was unchanged and ended with
`Sources: https://pypi.org/project/requests/` - the page it had fetched. The filter fails closed
on any surprise in the message shape.
---
### 55.2 — twelve tool defects from the graded transcripts
Each of these was identified in a v3 grade file with the transcript line that caused it. All are
domain-shaped; none is keyed to a test prompt.
**`fetch_page` served a glyph-encoded PDF as text (m06).** The Infineon datasheet's fonts carry
no character map, so extraction returns `/G73/G68/G32…` for every cell of every table; the tool
reported 28,716 characters and no warning, and the model reverse-engineered the glyph numbering,
got the one value the question turned on wrong, and answered the rest from memory. Now: when
more than 35% of a PDF's text is glyph codes (`/G123`, `(cid:123)`) the fetch FAILs with the
reason and the alternatives (a distributor's copy, the vendor's HTML page); between 3% and 35%
the header carries a warning that the tables may be unreadable. The Infineon file measures 78%.
**Open-access papers were unreachable by every route (m10, e13, h07).** PMC, PubMed and Europe
PMC's article pages all sit behind a browser check or cookie wall for a plain client; m10 tried
five routes to one open-access paper and got a stub from each, then found by itself that Europe
PMC's REST API serves the same full text as JATS XML with no check. `fetch_page` now tries that
API first for any URL naming a PMC article, and for a PubMed or Europe PMC record whose paper is
in PMC (resolved through the API's search endpoint). The XML is rendered to markdown with the
front matter as citation metadata (title, authors, journal, year, DOI - so the `CITE AS` line is
built from the article, not from a landing page), and the header says
`Retrieved via: …/rest/PMC…/fullTextXML`. When the API has nothing, the stub message says so, so
the model does not keep trying PMC routes.
**Bad parameters came back as JSON 422 bodies (m06, m03-v2).** `[MORE AVAILABLE: 10,716
characters]` was answered with `max_chars: 10716`, and the schema bounced the whole call.
`max_chars` is now clamped to its bounds instead; any other validation failure is returned as
prose with a 200 (`CANNOT DO THAT: the call was rejected before it ran - offset: Input should be
greater than or equal to 0 (you sent -5). Fix that parameter and call again…`), matching every
other failure in the suite; and the continuation hint names the cap.
**Two 504s arrived as nginx HTML (h05).** Open WebUI sets no tool-server timeout of its own
here, so the gateway's 90 s `proxy_read_timeout` was the only clock, and a 995-page reference
manual took longer than that to extract. The gateway now waits 240 s, PDF extraction is bounded
at 120 s inside the app (returning prose on timeout), and the slow pdfminer fallback is skipped
above 8 MB - so the app, not nginx, is what answers.
**`search=` landed on page-numberless contents entries (e12) and reported false sections
(h05).** RFC 9110's `.txt` rendering - every RFC in the v3 text format since 2019 - lists its
contents as `15.5.19. 418 (Unused)` with no leaders and no page number, so Change 49's skip did
not fire and two jumps returned the listing. A numbered line in the opening quarter of a document
whose text recurs later as a line of its own is now a contents entry. And the flush-caps heading
shape matched a running page header (`RM0008`) and a bit-field label (`XTPRE`) on the manual's
PDF text: a line that repeats four or more times is now a header, not a heading; a single
capitalised word is used only when no structured heading sits above it in the window (which is
the normal case in a man page and never in a numbered manual); and "Table of Contents" is itself
a recognised heading, so a jump into it is told so.
**`lookup_ip_or_asn` refused documentation addresses without naming them (m02), and returned no
origin AS (h10).** "Private, loopback or reserved" was true and useless: the model knew from
memory that 203.0.113.10 was TEST-NET and talked itself out of saying so in three rounds. The
refusal now names the block and RFC and states the fact in prose (never allocated, never
announced, treat it as a placeholder). And registration is not routing: RDAP says who a block is
allocated to, not who announces it, and h10 spent four extra fetches per round on the origin AS.
Every IP lookup now carries a `## Routing (BGP)` section from RIPEstat's routing view - announced
prefix, origin AS with holder, multi-origin flagged - and an AS lookup carries the holder and
whether it announces anything.
**`subnet_calc`'s documentation scope was a field (h04).** Three rounds reproduced the SLAAC
advisory paragraph almost clause for clause and skipped `Scope: documentation only` in the same
result: an advisory paragraph crowds out an advisory field. The same fact is now also a sentence
under the Scope line.
**`read_man_page` resolved a bare name to section 1 (m05).** cron's PATH behaviour is in
crontab(5) and cron(8), not crontab(1); a search of "crontab" landed in section 1 and reported
the term absent. On a `search=` miss with no section given, the sibling sections are now fetched
concurrently: if one contains the term it is shown instead, with a note; if not, the miss note
says which sections the name also exists in. (On Arch's cronie pages neither crontab(1) nor
crontab(5) mentions PATH - cron(8) does - so this prompt now gets "the name also exists in
section 5" rather than a hit; a different-name page is not something a search of one name can
reach.)
**`pcb_calc` mis-parsed unit suffixes and printed a negative impedance (h03, twice).** The
engineering-value parser reads a trailing "mm" as the prefix milli, so `"1.6mm"` became 0.0016 mm
and the microstrip formula printed **Z₀ ≈ −46.5 Ω** as a result. Board dimensions now have their
own parser (mm by default; mm, mil, thou, µm, cm, in accepted; a bare "m" is refused as not a
board dimension), and any impedance at or below zero is a CANNOT DO THAT naming the w/h ratio
and the likely unit mix-up.
**`matlab_run`'s parse-error hint did not recognise `\'` (h08), or a function defined after its
use.** A backslash-escaped apostrophe inside a single-quoted string is the commonest way to break
an `fprintf` in either dialect; the raw message said "syntax error", the model blamed Octave, and
a call was lost. The hint now names the fix (`''`). And a function defined in the script below
the line that calls it is "undefined" in Octave and required at the end of the file in MATLAB;
the hint now says which is which instead of reading as a typo.
**`_absent_note` had two false-negative paths and one false positive (e06, m06).** The
six-term cap on `_distinctive()` dropped the trailing terms of a long query, which is where a
model's guess sits (e06: "Port Jackson" in positions eight and nine, never checked); the engine's
own "Missing: Port Jackson" annotation inside a snippet put the absent words into the haystack;
and stripping the closing bracket off `RDS(on)` produced a term that matched nothing. The check
is now uncapped (the reported list is capped at four), engine annotations are stripped from
snippets before display or matching, balanced parentheses are kept, and matching ignores
punctuation and spacing so `RDS (on)` matches `RDS(on)`.
**The dosing hint fired on "dose-response" (m10, h07).** The epidemiology term is excluded
from `_PRACTICAL_MEDICINE`; "ibuprofen dose adult" still fires.
**`cite_line` produced "Clinic," (e13) and "csmith2" (m06).** An organisational author with no
comma - a multi-word name ending in an institutional noun, or containing "of"/"for" - is cited
whole; a PDF `/Author` that is a username (one token, lower case or with a digit) and a `/Title`
that is a filename or a template name are discarded, so no `CITE AS` is built from them.
**`lookup_rfc`'s description** now says it also answers "which RFC says X", the question e12
answered from memory (RFC 7161 for the teapot code; it is RFC 2324).
---
**Measured:** 139/139 smoke cases pass (123 before, 16 added for this change). Every item above
probed through the gateway on its motivating input: the Infineon PDF is refused at 78% glyph
codes; PMC3898757, PMC8313037, PMC3976660 (a 7 KB article), PubMed 24459154 and Europe PMC
MED/30241358 all arrive via the API with `CITE AS` lines from the JATS front matter;
`max_chars: 10716` is served; `offset: -5` is prose; RFC 9110 `15.5.19` skips the contents entry
and reports section '15.5. Client Error 4xx'; 203.0.113.10 names TEST-NET-3; 8.8.8.8 carries
AS15169 and 104.16.133.229 AS13335; `12mil`/`8 mil` parse to 0.305/0.203 mm; the `\'` and
function-order hints fire; "Zorbulanquex" is reported absent; "dose-response" does not draw the
DailyMed hint and "ibuprofen dose adult mg" does. Fresh-chat spot checks through the UI, with the
prompts verbatim: six prompts, one chat each, model row unchanged.
- **e05** (3 calls, as v3): answer text unchanged; `Sources: https://pypi.org/project/requests/` appended by the filter - the cited-URL loss of all three rounds closed.
- **m02** (4 calls): both RDAP refusals name TEST-NET-2/3 and RFC 5737; the answer opens with that fact and treats the addresses as placeholders, which three rounds of memory-based hedging never quite did.
- **h03** (3 calls, v3: 5): `"12mil"` and `"1.53mm"` parsed correctly at the first attempt - Z₀ 125 Ω where v3's first call printed −46.5 Ω - so the wasted round trip is gone; 54 mil and 110 mil delivered, both traces failed.
- **m06** (8 calls, v3: 12): both exact IRLZ44N PDFs refused as 78% glyph codes, Mouser's copy timed out, and the model read Vishay's IRLZ44 sheet - and said so in its first sentence, labelling every figure as the variant's. The v3 fabrication ("0.044 Ω (4.5 V spec)", from memory, presented as the datasheet's) did not recur; the exact IRLZ44N values are still unreachable because every readable copy the tools found is a different part, which the answer states. The failure mode moved from *wrong numbers presented as read* to *labelled substitute source*, which is the change's purpose.
- **h10** (8 calls, v3: 12, v2: 17): the four ipinfo fetches are gone; origin AS13335 and the announced prefixes (104.16.128.0/20, 2606:4700::/44) came from `lookup_ip_or_asn`'s routing section, and the answer labels them "RDAP + RIPEstat routing view".
- **e13** (6 calls): the PMC review (PMC7347500) arrived through the Europe PMC API with its `CITE AS` line where v3 got a Cloudflare stub; the answer ends with all three read pages - appended by the filter, since the text named none of them. The dosing hint did not fire (no dosing term in the query), as intended.
Transcripts are in the session scratchpad, not in `prompt-testing/` - these are checks that the changes fire, not a round.
**Measured, v4 round (run and graded 2026-09-12; `prompt-testing/SCORES.md`, one `grade.md` per
prompt):** mean **8.90** (v3 8.85), 35 pass / 4 partial / 1 fail, 44 points lost (v3 46).
Correctness 92 %, calibration 88 %, tool use 91 %, communication 88 %.
- **55.1** put a URL on every answer that read a page: 16 of 16 opportunities, plus one the
model wrote itself (`e05`) — 17 of 22 against v3's 1 of 22. The five without are four
search-only answers (`e07`, `e14`, `h01`, `m01`) and `h10`, whose tools emit no trailer. Seven
rubric points recovered (`e05`, `e10`, `e13`, `e15`, `e20`, `m08`, `h07`, half on `h05`). The
≥ 20-of-23 target is not met on the mechanical count and is met on every opportunity the
filter has. Four defects on the one long research turn (`m10`): a "Key sources:" block is not
recognised as the model's own attribution; a bare `doi:10.…` in the body is not matched to its
`doi.org` entry; a trailer's `CITE AS` DOI and its URL count as two entries for one document;
the first-six cap kept a cookie-wall stub and a wrong-paper fetch and dropped the paper the
answer used. Also visible (`e06`): the filter cites what the tools read, not what the answer
says — a Wikipedia URL under a constitutional claim the lead does not make.
- **55.2** — seven of the twelve confirmed in a real chat on the prompt that motivated them, each
changing the outcome as intended: glyph PDF (`m06`, twice at 78 %, 12 → 6 calls, the v3 −2
gone), Europe PMC API (`m10`, three articles — the effect sizes quoted from the paper instead
of recalled), contents-entry skip + "Contents" heading + PDF header suppression (`e12` 11 → 4
calls; `h05` three correct section notes where v3 had two false ones), BGP routing (`h10`
12 → 8 calls, the ipinfo fetches gone), scope sentence (`h04`, the documentation prefix
carried for the first time in four rounds; 6 → 9), `pcb_calc` units (`h03`, first-attempt
parse, no negative impedance), absent-note uncapped (`h01`, terms at positions 8–9 reported).
Five not exercised because the model did not reach the tool or the path: TEST-NET refusal
(`m02` chose `lookup_rfc`), sibling man sections (`m05` made no call), both `matlab_run` hints
(`h08`/`m07` wrote no `\'` and no script function), prose 422 (no bad parameter), dose-response
(no such query). `cite_line` fired right on three pages (`h05` "STMicroelectronics", `m06` PDF
metadata discarded, `e13`) and missed two new shapes: "ElectricalLicenseRenewal com" → `com,`
(`e16`) and "Kelly Johnson-Arbor; MD Medical Toxicologist" → "Johnson-Arbor et al." (`h07`).
The `lookup_rfc` "which RFC says X" wording drew a topic query on `m09` that got NO RESULTS
(the index search is a title search).
- **New harness defects from the round:** `wiring_calc` reads a trailing "ft" as femto
(`"100ft"` → 1e-13 m, success, 0.00 % drop; `e16`, two calls lost and a hand-computed figure
wrong by 4×) — the `pcb_calc` bug's sibling; `fetch_page` on a reports index page loses the
document links (`h02` never found the PDF v3 read, and answered from snippets and a wrong
fiscal-year inference: 9.5 → 5.5); a PDF snippeted as raw bytes is unrecognisable; the
string-type lint note repeats once per line (12 copies, `h06`); a "Cookies must be enabled"
page returns as a success with a trailer; the JATS renderer dropped the authors on
PMC8066601; PDF text layers that split words ("configurati on") defeat exact-heading
`search=` (`h05`).
- **Model-side, no harness cause:** `e19` 10 → 0 — the clock was not called for the first
time in four rounds and a training-data date was given ("Saturday, 19 July 2025"; the
thinking names "the current timestamp" as its source and does not fetch it). The universal
fix is to put the date in context (Open WebUI's `{{CURRENT_DATE}}`, or the status filter's
inlet), which changes what the prompt tests — the user's call. Same decision going the other
way on `m04` (wrote a parameterised function instead of asking; 9 → 7), `m05` (no man page;
9 → 8), `e06` (s.125 recited after a lookup that did not contain it); and going right on
`m08` (2.5 → 10, the office-holder looked up this time). Volunteered extras wrong while the
asked-for facts are right, ten prompts: `e01`, `e12`, `e16`, `e18`, `h03`, `m01`, `m03`,
`m07`, `m10`, `h07`.
---
## Change 56 — the v4 round's easy wins
The v4 round scored **8.90/10** against v3's 8.85 (35 pass / 4 partial / 1 fail; 44 points
lost). Of those, the grade files pin twelve to a harness cause that is a few lines each, and one
(`e19`, the only 0) to a fact that was simply not in context. This change takes all thirteen and
nothing else. Each is domain-shaped — a unit, a page shape, a lint rule, a query shape — and none
is keyed to a test prompt. **Nothing here has been run against the model**: the smoke suite is
the whole measurement, by instruction, and the v5 round is the test.
---
### 56.1 — `wiring_calc` reads feet as feet
**Symptom.** `e16`: `length: "100ft"` was accepted, parsed as 1 × 10⁻¹³ m (the trailing "f" is
femto to the engineering-notation parser, the same defect Change 55.2 fixed for `pcb_calc`),
and the tool reported a 0.00 % drop as a success. Two calls were lost to it and the figure the
model then worked by hand was wrong by 4×.
**Change.** A `_cable_length_m` reader in front of the three voltage-drop calculations. Metres
by default; an attached unit may be m, cm, mm, km, ft, feet, `'`, in, `"` or yd; anything else
("100k") is refused with "Could not read" rather than guessed. The header prints what was
understood — `30.48 m (100 ft)` — so a wrong reading is visible in the result, not hidden
behind a plausible number. The schema says the same.
**Result.** Smoke: `"100ft"` → 30.48 m, 5.29 % on 12 AWG at 20 A / 120 V (the `e16` figure);
`"30m"` → 30 m, not 30 mm; `"250 ft"` on three-phase → 76.2 m.
---
### 56.2 — `answer_sources` 0.2.0: the four defects from `m10`
**Symptom.** The research turn that motivated the filter got the worst of it: a `**Key
sources:**` block of the model's own was not recognised (the label did not start with the
word), so a second, worse list was appended under it; a bare `doi:10.…` in the body did not
match its `doi.org` entry; a trailer's `CITE AS` DOI and its page URL counted as two documents,
so one paper filled two of the six slots; and the cap kept a cookie-wall stub and a wrong-paper
fetch while dropping the paper the answer used.
**Change.** The own-sources check now accepts up to two words before `sources`, `references`,
`citations` or `bibliography` and a `used`/`consulted`/`read`/`cited` suffix, through markdown
emphasis; a DOI anywhere in the body matches the read set; each `Sources:` line is one document
(the page URL shown, the DOI kept as an alias); the cap is eight. The URL-in-body rule is
unchanged. The stub half of the fourth defect is 56.3.
**Result.** Offline, on the `m10` message: no second block; on `e05`'s: unchanged. Deployed to
the `answer_sources` row (7,734 characters, active, global).
---
### 56.3 — a cookie wall is a stub
**Symptom.** `m10`: PubMed's "Cookies must be enabled" page — 76 characters, served with a 200 —
came back as a successful fetch, with a provenance note describing an abstract that was not
there and a `Sources:` trailer the filter then listed.
**Change.** `cookies must be enabled` and `enable cookies for` join the stub markers; the fetch
FAILs with the client-rendered/gated message and emits no trailer.
---
### 56.4 — `cite_line`: a site's name is not an author, and a credential is not a co-author
**Symptom.** `e16`: "ElectricalLicenseRenewal com" as `citation_author` produced `CITE AS: com,
…`. `h07`: "Kelly Johnson-Arbor; MD Medical Toxicologist" produced "Johnson-Arbor et al." —
a single author cited as several.
**Change.** A name whose last word is a top-level-domain word (com, org, net, gov, io, …) is an
organisation and is cited whole. `toolbase.clean_authors` drops an author entry that is a
credential or begins with one (MD, PhD, RN, FACEP, "Medical Toxicologist" …) and strips trailing
degree tokens; it runs on HTML meta authors, PDF metadata and JATS front matter alike.
**Result.** Smoke: the poison.org byline cites `Johnson-Arbor,` and never `et al.`; the
licence-renewal page never `CITE AS: com,`.
---
### 56.5 — one lint note per rule
**Symptom.** `h06`: twelve double-quoted strings, twelve copies of the same "string type" note —
a screenful in which any other finding was invisible.
**Change.** `_fmt_findings` groups by rule; a rule that fires more than once prints once with
its line numbers: `- lines 1, 6, 11 (12 places): …` (first eight lines, then "and N more").
---
### 56.6 — the `std`/`var` weight flag, stated the right way round
**Symptom.** `m04`: the delivered function's comment, docstring and closing notes all said
`std(X, 0, 1)` is the population statistic and told the user to swap to `std(X, 1, 1)` for the
sample one — every clause inverted, and the same inversion v2's grade recorded. It is a belief
(the thinking says "w=0 → divide by N"), so no prompt wording reaches it.
**Change.** A `SEMANTIC` lint rule fires on any `std(…, 0|1, …)` / `var(…, 0|1, …)` and states
the fact in the run result: w=0 normalises by N−1 (the sample statistic, the default), w=1 by N
(the population statistic). A bare `std(x)` draws nothing.
---
### 56.7 — `pcb_calc` trace_width prints the width at the other copper weight
**Symptom.** `h03`: the question turns on "would 2 oz copper help"; the tool gave the 1 oz width
and the model worked the 2 oz figure by hand, with a square-root scaling it should not have.
**Change.** The result carries one more line: the same cross-section at the other weight (1 oz
↔ 2 oz), with the mil and mm width and the fact that width scales inversely with copper
thickness. The IPC-2221 area is unchanged; it is the same number divided differently.
**Result.** Smoke: 3 A, 10 °C rise → "At 2 oz copper … 26.9 mil".
---
### 56.8 — the dosing hint names the product
**Symptom.** `h07`: the DailyMed hint fired and, for the first time, the model searched
DailyMed — then took the generic 500 mg label's 4,000 mg ceiling for a branded extra-strength
product whose label says 3,000 mg, with both figures in front of it.
**Change.** One clause on the hint: "Labels differ by product and strength - read the label for
the product the question names, not a generic one." A fact about labels, not a rule about the
answer.
---
### 56.9 — `lookup_rfc` falls back to phrases
**Symptom.** `m09`: "which RFC says X" drew a topic-shaped query ("DNS domain name maximum
length 63 octets label") that got NO RESULTS, because the index search matches words in RFC
titles. The model recovered by guessing the right RFC number, which is not a method.
**Change.** `_rfc_title_search` tries the whole query, then each two-word phrase from it, then
each word of five letters or more (six tries at most). The listing says which phrase matched
("these titles contain 'Domain Names'"); the NO RESULTS text says the search matches titles, not
topics, and shows what a title-shaped query looks like.
**Result.** Smoke: that query → RFC 1035.
---
### 56.10 — document links survive extraction
**Symptom.** `h02`: the investor-relations reports index lists each report as a title, a date and
a "Download" link, and trafilatura discards the link-dense grid whole, so the model read the
index and could not get from "Interim results" to the PDF it needed — the PDF v3 had read. It
answered from snippets and a wrong fiscal-year inference instead (9.5 → 5.5).
**Change.** Before extraction, an anchor whose target looks like a document (`.pdf`, `.xlsx`,
`/document`, `/download`, `/attachment`, …) or whose text says so becomes a plain span in place:
`Download (https://…/reports/10/document)`. Ordinary navigation links are untouched.
**Result.** Smoke: the reports index now carries its document URLs.
---
### 56.11 — split-word headings, and a cross-reference is not the section
**Symptom.** `h05`: the mirror PDF's text layer renders the heading as "Clock configurati on
register", so an exact `search=` for the heading landed twice on a cross-reference to it
("refer to Section 4.3.2: Clock configuration register") and never on the heading. The model
found the section by paging to a guessed offset.
**Change.** The first pattern `textwin` tries is heading-shaped — optional section number, the
needle with whitespace tolerated inside its words, end of line — so the split heading is found
before any prose match. When the chosen match is preceded by a "refer to Section"/"see
Section" cue, the jump header says so and suggests the section number or a term from the
section's first paragraph.
---
### 56.12 — JATS author groups
**Symptom.** `m10`: PMC8066601 rendered with no authors — the archive labels the
`<contrib-group content-type="author">` and leaves each `<contrib>` bare, and the renderer
looked only for `contrib-type="author"` on the contrib.
**Change.** Labelled contribs first; then contribs inside a labelled author group; then any
contrib carrying a surname or a collaboration name. Names pass through `clean_authors`.
---
### 56.13 — the date is in context
**Symptom.** `e19` 10 → 0: for the first time in four rounds the clock was not called, and a
training-data date was given ("Saturday, 19 July 2025"). The thinking names "the current
timestamp" as its source as though it were in context; it was not.
**Change.** The system prompt's second paragraph is now
`Today is {{CURRENT_WEEKDAY}}, {{CURRENT_DATE}} (the user's local date)`, which Open WebUI
expands server-side (verified: `prompt_template` in `resolve_system_prompt`, container zone
America/New_York, the user's), with the instruction to use it for anything dated and to call
`get_current_timestamp` for the time of day, UTC or the zone name. Two lines; the rest of the
prompt is byte-identical. Written to the `qwen3.8:latest` row's `params.system` (14,608
characters; sampling parameters, tool binding and access operator unchanged — the update endpoint
re-creates the operator row, same principal and permission).
This changes what `e19` tests, from recall-vs-tool to context-vs-recall; the rubric carries a
dated revision note and accepts either the prompt or the tool as the source. The v1–v4 grades
stand on the earlier wording.
---
**Measured:** 154/154 smoke cases pass (139 before, 15 added for this change: five `wiring_calc`
unit readings, two `pcb_calc`, three `matlab_run`, two `lookup_rfc`, three `fetch_page`). The
`answer_sources` change was exercised offline on the `m10` and `e05` messages. Both deployed rows
were read back after the write. The page cache was flushed (`owuitools:page:*`) so the
document-link rewrite is what the next fetch serves.
**Not measured:** no prompt has been run against the model, by instruction. The v5 round is the
measurement for all thirteen items, and `e19` in particular now tests a different thing.
---
## Change 57 — plot type that fits the canvas, and the key under it
Reported from use, not from a graded prompt: "for the matlab renders, try to space out everything
more, it is often that text will overlap. Also, move the legend to below the render so that it
doesn't overlap." Both are the same picture — a 900 × 675 plot whose text was rendered at roughly
twice the size it was asked for, with an opaque key sitting on the curves.
Three reproductions, each a plot a model might reasonably write: three curves with a long title
and a three-entry legend; a 2 × 1 subplot stack; a bar chart with six named categories. In the
first the title ran off the right edge, the ylabel was drawn across the y tick labels (the minus
signs vanished under it), the xlabel was cut off by the bottom of the canvas, and the legend
covered a fifth of the data. In the third the six category names ran into each other and the y
tick labels were clipped to their last digit — "25, 0, 5, 0, 5, 0" for 25 down to 0.
None of it was a gnuplot limitation. It was four separate layout faults, all fixed here, all in
the renderer rather than in anything the model writes.
---
### 57.1 — a bigger canvas without bigger type
**Symptom.** Every character on every plot was about twice the size it should have been for the
canvas it was on: the 10 pt axes font drawn at 16.7 pt, the 11 pt title at 18.3 pt, on a canvas
900 px wide. That is the whole cause of the clipped title, the ylabel over the tick labels and the
bar-chart names running together. Raising the canvas size did not help, which is what makes the
cause non-obvious.
**Cause.** `-S900,675`. Octave's `print.m` reads `-S` as "a six-inch figure blown up to this many
pixels" and scales every font in the figure by `width / 6 / screenpixelsperinch` — at 72 ppi in
this container, `width / 432`, so 2.08× at 900 px and 2.55× at 1100. The type grew with the
canvas, so no size was ever big enough. The shim's `-F:8` was an attempt to hold it down and could
not: `-F` is multiplied by the same factor.
**Change.** Size by paper instead. The shim sets `paperposition` to `width/72` inches with
`paperpositionmode manual` and passes no `-S`, which takes the other branch of `print.m`: the
canvas is paperposition × 72 and the scale factor stays 1, so text keeps the size Octave gave it
and a bigger canvas buys real room. 1100 × 825 by default. The shape follows the figure — a script
that asks for a 1200 × 400 figure gets a 1100 × 440 PNG, clamped to between 0.4 and 1.5 — and a
script that passes its own `-S` is left alone entirely. `-r72` goes with it, because the JPEG
terminal multiplies paper inches by the print resolution and turned the same canvas into a
2292 px image with 10 pt type marooned in it; `pngcairo` ignores the setting.
**Result.** Tick labels and axis labels at 12 pt on an 1100 px canvas (57.2 chooses the 12),
against 16.7 and 18.3 on 900 px before. Nothing is clipped in any of the three reproductions. Smoke asserts the
canvas exactly: PNG carries width and height at a fixed offset, so the base64 prefix of the
returned data URI *is* "1100 × 825" — it fails if the shim leaves the path, if `-S` scaling comes
back, or if the canvas changes size.
---
### 57.2 — axis labels the size gnuplot reserved room for
**Symptom.** With the type fixed, the xlabel still landed on the row of x tick labels, and the
ylabel still crowded the y tick labels.
**Cause.** gnuplot lays a plot out in character units of the *terminal* font. Octave sets that
terminal font from the axes font size — 10 pt — and then draws axis labels at 11, because
`labelfontsizemultiplier` is 1.1. The space reserved below the tick labels is a 10 pt line and the
label drawn into it is an 11 pt line, so it rides up into the ticks. Confirmed against gnuplot
directly, same plot and same key: xlabel at 11 pt overlaps, at 10 pt clears.
**Change.** The shim sets `labelfontsizemultiplier` to 1 on every data axes for the duration of
the print. The title keeps its 1.1 and its bold weight — it sits in the top margin with nothing
under it to collide with.
With the tie between reserved space and drawn text in place, the size itself can be chosen on its
merits, and Octave's default 10 pt — chosen for a 560 px figure on a screen — is too small on an
1100 px canvas that a chat window then shows at 700. The shim sets 12 pt, and only where the
script did not set a size itself: `fontsizemode` records which, and an axes that says 16 pt keeps
16 pt and gets it back after the print.
**Result.** Clear separation between tick labels, axis labels and everything below them, on all
three reproductions and on log axes, 3-D and subplots. Type at 12 pt on 1100 px still leaves the
six bar-chart category names room side by side, and is legible when the picture is displayed at
700 px.
---
### 57.3 — the key under the axes, not over the data
**Symptom.** The requested change, and the reason it was requested: an inside legend sits on top
of what it labels. `'best'` does not help — this backend does not implement it, warns, and puts
every legend top-right regardless of what the script asked for.
**Change.** For a figure with one plot in it, every legend that is not already outside is moved to
`southoutside` for the print and put back afterwards. gnuplot reserves the space for an outside
key and shrinks the plot to fit, so nothing can collide with the data.
The frame comes off with the move. gnuplot packs an outside key flush against the x-axis label — a
one-pixel gap, whatever the type size, and the label's own offset moves both together — so the
box's top edge is drawn through the descenders of that label and reads as an underline under it.
Out on the white margin, with no plot lines behind the text, the frame was only decoration.
Two legends are left alone. One a script deliberately put outside already (`'eastoutside'`), which
is a layout decision rather than the default falling where it falls. And every legend in a figure
with more than one plot: a subplot grid has no margin under each plot to give away, only the gap
before the next one's title, and a key dropped into that gap lands on both — verified, it overlaps
the xlabel above it and the title below it. Inside is where they fit there, and 57.4 keeps them
readable.
**Result.** Single plots: the key is a caption under the axes, three entries or six. Subplots: the
keys stay where the script put them and nothing else on the figure overlaps any more.
---
### 57.4 — an opaque key is for an inside key only
**Symptom.** With the key moved below, the xlabel printed as "from the triaaer edae": the tail of
every g, y and p in it was erased.
**Cause.** Change 42's build-time patch (`patch-legend-opaque.sh`) emits `set key opaque`
unconditionally. Inside the axes that is right and still is — gnuplot's default leaves the key
transparent, so plot lines run through the legend text on exactly the busy plot where a legend
matters most. Outside the axes the fill has no plot lines to hide and lands on the label instead.
**Change.** The patch now emits `set key opaque` when the key is inside and `set key noopaque`
when it is outside. The `noopaque` is stated rather than left to the default, because `set key
opaque` from an earlier axes persists through the rest of a multiplot stream. The patch keeps its
anchor check and still exits 0 if an Octave upgrade moves the code.
**Result.** Descenders intact under an outside key; inside keys unchanged.
---
**Measured:** 156/156 smoke cases pass (154 before, 2 added: a plot with a legend, and a
script-sized wide figure, each asserting the canvas through the PNG header). Nine figures rendered
and inspected by eye, before and after: three curves with a long title and legend, six-entry
legend on log axes, six-category bar chart, 2 × 1 subplots, `peaks` surface with a colour bar, a
1200 × 400 figure with an `eastoutside` legend, an axes that sets its own 16 pt, `saveas` instead
of `print`, and a JPEG. The last four exist because each takes a different path through the shim. End-to-end through the gateway
as well, not only in the container: the tool returns a 1100 × 825 data URI.
The picture is 0.9 MP where it was 0.6, and Open WebUI hands it to the model as an input image, so
a returned figure costs about half again as many vision tokens as it did.
**Not measured:** no prompt has been run against the model, by instruction. Nothing here changes
tool text, so it does not move a graded answer by itself; what it changes is whether a plot the
model hands back can be read.
---
## Change 58 — closing the MATLAB gap where it can be closed
Asked from use: "would it be possible to modify Octave to be even closer to how MATLAB
functions? There are some small discrepancies that can cause issues for more complex
operations."
The investigation came first and killed the obvious answer. A throwaway Debian 13 image with
**Octave 9.4** was built and run against the same probes as the 7.3 in the image: the upgrade
adds `normalize` and `tensorprod` and nothing else. `contains`, `strlength`, `string`, `table`,
`datetime`, `histcounts`, `round(x,n)`, `sum(...,'all')` and `maxk` are missing in both, and
every semantic probe — error identifiers, `&&` on a non-scalar, `arguments` validators, `A\b`,
the RNG stream, the default `save` format, `num2str` on an int64 — behaved identically on the
two versions. A version bump costs a rebuild and buys nothing here.
What it does buy is elsewhere, and this change takes the six cheapest pieces of it.
---
### 58.1 — a script may put its functions at the end, as MATLAB does
**Symptom.** MATLAB (R2016b+) hoists a script's local functions: they go at the END and are
called from anywhere above. Octave defines a function when execution reaches the definition, so
the same script dies on `'double_it' undefined`. Correct, ordinary, modern MATLAB, refused. It
cost `m07` a whole tool call in the v3 round, and the error reads like a typo, so the model's
instinct is to rewrite code that was already right.
**Change.** `matlab_lint.split_local_functions` lifts the trailing `function` blocks out of the
script and `matlab.py` writes them as files beside it, which is where Octave looks. Two
properties matter as much as the lift itself:
* **Line numbers do not move.** The lines the definitions came from are replaced with blank
lines, and each lifted file is padded with as many leading blank lines as it had above it, so
an error inside one reports the line the model actually wrote. The stack frame says
`main.m line 5 (in pick)` rather than naming a file nobody created.
* **It refuses anything it does not fully understand** — a nested function, a classdef, code
after the definitions, two functions of one name, an unbalanced `end`, a signature it cannot
read, more than forty functions. Those files run exactly as they did before. Mangling a file
that was about to work is worse than not helping it.
The name that becomes a filename is checked twice: by the parser, which accepts an identifier
and nothing else, and again at the write itself, against the pattern the helper files use.
**Result.** `tests/test_local_functions.py` — 25 cases, pure Python, no container: the lift, the
line-number preservation, `end` used as an index, a one-line `if/end`, a switch inside the
function, the word `function` inside a string and inside a comment, and each refusal. Live: a
two-function script runs; a helper file of the same name wins over the lifted one; an error
inside a lifted function reports `main.m line 5 (in pick)`; recursion works; an unterminated
tail works; a nested function still fails with Octave's own parse error.
---
### 58.2 — Octave's parser as a second compatibility linter
**Symptom.** The regex linter catches the Octave-isms it has rules for. `x .*= c`, a bare
newline inside parentheses and several others have no rule, and a regex over a stripped copy of
the source can never be as sure about the dialect as the parser that implements it.
**Cause of the gap.** `Octave:language-extension` is exactly this signal and the driver turned
it OFF, with a comment saying it was unusable: Octave's own library is written in the same
dialect, so a real error disappeared under hundreds of warnings about files nobody can edit.
**Change.** The missing piece was that every one of those warnings names its file. The driver
turns the warning on — last, after the `pkg load`s and the `addpath`, so none of that machinery
is parsed with it enabled — and `run.sh` drops the library ones by path before anything is
measured against the output cap. A warning is three parts (the line, a `called from`, its
indented frames), and dropping only the first left orphaned frames that read like a stack trace
for an error that never happened, so the filter takes the block.
What survives is the user's code. `matlab.py` turns it into findings with line numbers, drops
any line the linter already reported, caps at twelve, and removes them from the raw warnings so
one defect is not printed twice.
**Result.** `x .*= 2` — which no rule covers — now comes back as "Octave's parser read `.*= 2;
used as operator` - an Octave extension, not MATLAB syntax", and the verdict is the existing
"Ran in the sandbox, but THIS IS NOT VALID MATLAB". Scratch runs are not told, the same policy
the linter's own `octave_only` findings follow.
---
### 58.3 — eight MATLAB functions this Octave does not have
**Change.** `octave/shim/` gains `contains`, `strlength`, `matches`, `maxk`, `mink`,
`histcounts`, `discretize` and `rmmissing`, joining `print` on the path ahead of everything.
They are written in plain MATLAB, and the policy for all of them is in `contains.m`: implement
the documented behaviour for the forms that are actually written, and raise a clear error for
the rest. `histcounts(x)` with automatic binning says so instead of guessing a binning rule —
MATLAB's is documented and non-trivial, and a shim that picked a different one would hand back a
different histogram with nothing to show for it. `rmmissing` refuses cells rather than inventing
a meaning for missing text.
`check-shims.sh` runs at image build time and does two things that cannot be done later:
* **Drops** any shim whose name Octave has since acquired. This directory shadows everything; a
stale four-signature `contains` replacing a complete one is the exact failure the shims exist
to prevent. Octave gaining a function is good news, and the right response is to delete ours.
* **Tests** what survives, against the behaviour MATLAB documents — including the edge
conventions that are why these are not one-line aliases: `histcounts`' last bin is closed
where `histc`'s is not, `maxk` is column-wise on a matrix and returns a row for a row.
A failure fails the build; verified by breaking a shim on purpose and watching it.
`_MATLAB_ONLY` and `_OCTAVE_EQUIVALENT` lose the five names that now work, so the model is no
longer told to rewrite `contains` into `strfind`.
**Not shimmed, deliberately:** `round(x, n)` and `sum(..., 'all')`. Those are extra arguments to
functions Octave already has, so the shim would have to shadow a hot builtin — measured at
**4.6x** on the shadowed call (20,000 `sum` calls: 0.080 s to 0.369 s), paid by every call in
the session including the ones Octave's own library makes. 58.5 handles them for free instead.
---
### 58.4 — two preferences, and the ones not taken
**Change.** The driver sets `print_empty_dimensions(false)`, so an empty array prints `x = []`
as MATLAB does rather than `x = [](0x0)` — output the model reads back to the user — and
`save_default_options("-mat7-binary")`, because `save("data.mat", "x")` was writing an Octave
TEXT file with a `# Created by Octave` header. A user opening that in MATLAB gets nothing. The
header now reads `MATLAB 5.0 MAT-file`.
Octave's `--traditional` flag sets both of these and is documented as "closer MATLAB
compatibility", but it is not taken wholesale: it also sets `struct_levels_to_print(0)`, which
prints `val: 1x3 matrix` instead of a struct's contents, and `beep_on_error(true)`.
`fixed_point_format(true)` is the interesting refusal. It makes numeric display genuinely closer
to MATLAB — `[1000.5 2000.25; 3000 4000]` prints under a `1.0e+03 *` header in MATLAB, and
element by element here — and it was left off anyway. This tool exists to give the model numbers
it can quote, and a display where every value must be multiplied by a header two lines up is a
transcription error waiting to happen. Fidelity lost, accuracy kept.
---
### 58.5 — MATLAB call forms this Octave refuses
**Symptom.** `round(pi, 3)` gives "Invalid call to round"; `sum(A, 'all')` gives "sum:
unrecognized type argument 'all'". Both are correct MATLAB (R2014b and R2018b). Neither message
says so, and "invalid" reads as "you wrote a bug".
**Change.** Two patterns in `_classify_error`. They name the MATLAB version the form comes from,
give the local rewrite (`round(x * 10^n) / 10^n`, `sum(A(:))`), and then split on purpose: a
scratch calculation should be rewritten, a deliverable should keep the MATLAB form and say that
this sandbox could not run that line. The `'all'` pattern matches the source as written rather
than the literal-stripped copy — its subject IS a string, and the stripped copy has blanked it,
the same trap the linter documents for its own raw rule.
---
### 58.6 — a function named after a MATLAB built-in
**Symptom.** From `m04`, v4: the answer defined `function normalize(X)`. `normalize` is a MATLAB
built-in (R2018a+), so the delivered file shadows it in the user's MATLAB — and nothing here
noticed, because `normalize` does not exist in this Octave at all. A name that is free in the
sandbox and taken in MATLAB is invisible to every other check that runs.
**Change.** The names a script defines — lifted local functions and helper files — are checked
against the MATLAB-only table plus a curated list of core names, each one verified present in
this image and each a plausible thing to call your own function. A hit gets one short section
saying yours wins silently, including for calls that meant the built-in. The list is
deliberately partial: a missing name costs a note, never a wrong one. Scratch code is nobody's
deliverable and is not told.
---
**Measured:** 166/166 smoke cases pass (156 before; 10 added, 3 rewritten because they asserted
limitations this change removes — the `contains` advice, the "defined below the line that calls
it" hint, and the equivalent-advice path, which now uses `split`, still missing). 25/25 unit
cases in `tests/test_local_functions.py`. 27 shim assertions at image build time, and the build
was verified to fail when a shim is wrong. Eight adversarial live cases: a helper that collides
with a lifted name, a function hidden inside a block comment, `function`/`end` inside a string, a
nested function, three functions at once, a user function shadowing a shim, an unterminated
tail, and recursion inside a lifted function.
**Not measured:** no prompt has been run against the model, and the MATLAB half of every claim
here is from documentation — there is no MATLAB in this house to check it against. The `symbolic`
package (SymPy-backed `syms`, `solve`, `vpa`, `laplace`) was tested and works offline under the
sandbox's full hardening in 0.03 s to load and 1.15 s for the first call, and is **not** included:
it spawns a Python child, and `timeout -s KILL` kills only the Octave process, so a job that dies
mid-symbolic leaves an orphan in a container that serves every other job. Process-group cleanup
is its own change, with its own review.
---
## What was and was not measured
**Measured:** 66/66 existing smoke cases still pass, and 107/107 after Change 53. 39/39 targeted cases for the new
behaviours pass through the gateway. Two graded prompts re-run end to end through the chat UI,
both fixed. Engine availability, model row contents and filter rows verified directly.
**Not measured:** the full 40-prompt suite has not been re-run, by choice — spot checks were
the agreed level for now. The per-bucket targets from the plan (recall 30.5 → ≤15, attribution
28.5 → ≤14, tool gaps 20.5 → ≤8, loop 10 → 0, arithmetic 3.5 → 0) are therefore unverified in
aggregate. The presence-penalty change in particular has not been tested against the loop that
motivated it.
---
## Change 59 — a sub-agent with a real machine in front of it
**Symptom.** The assistant could reason about a script but never try one. Asked whether a
PowerShell snippet works, it could only read documentation about PowerShell; asked why a
build fails, it could only guess from the error text it was given. Every other tool in this
suite returns a fact about the world. There was no tool that returned a fact about *running
something*.
**Cause.** Nothing structural — the capability had simply never been built. The Octave sandbox
(Change 34) is the nearest thing and is deliberately narrow: one interpreter, no network, no
filesystem that survives.
**Change.** `vm_agent`: one tool that hands a task to a *second* LLM turn-loop — the same
`qwen3.8:latest` on the same Ollama, with its own system prompt and its own thirteen tools —
which drives a throwaway Ubuntu or Windows VM. The main model's surface stays at one tool and
never carries VM-control instructions, screenshots or transcripts. Everything the VM touches
is destroyed afterwards except a per-project folder on the ZFS array, which is a Gitea working
tree.
Five new components: a host daemon (`vmd`) holding everything privileged behind a unix socket;
an agent container holding no credential and able to reach exactly one address; an nginx
gateway holding the two secrets; a netns guard; and a GTK4 desktop tool for the operator to
unfreeze a base image.
The rest of this entry is the things that were **measured**, several of which contradicted the
design they were meant to confirm.
### 59.1 — the primary network control was doing nothing
**Symptom.** The sandbox unit carried `IPAddressAllow=0.0.0.0/0` alongside a deny list for the
private ranges, on the assumption that longest-prefix match would let the specific denies win.
Everything appeared to work: a probe from the sandbox uid could not reach `192.168.1.10:7200`,
`172.17.0.1:11434` or `127.0.0.1:3000`.
**Cause, and it is mine.** `systemd.resource-control(5)` applies the two lists in a fixed
order: granted if the address matches `IPAddressAllow=`; otherwise denied if it matches
`IPAddressDeny=`; otherwise granted. There is no arbitration between them. `0.0.0.0/0` matched
every address at step one, so the entire deny list was dead code. What made it *look* correct
was the second layer — an nftables table matching on the qemu uid — quietly covering for it.
**Change.** Deny-only, with a comment saying why there must never be an `IPAddressAllow=` line
in that file. Rule three ("otherwise granted") is what grants the internet; it needs no help.
**Result.** With the nft table deleted, the corrected unit blocks all ten must-fail targets and
leaves `1.1.1.1` and `8.8.8.8` reachable. With the nft table restored and `IPAddressDeny`
stripped from a scratch unit, the same suite passes again. `tests/vm_host_asserts.sh` now does
both of those every run.
**Generalisable lesson.** Two independent controls are worth having and worth *testing
independently*, because in normal operation either one will hide the other's absence. The test
that found this is the one that turns each layer off in turn — not the one that checks both are
loaded. This is the same reasoning `netguard/firewall.sh` already applies to itself
("a firewall that silently failed to apply is worse than none"); it just had not been applied
to the layer above it.
### 59.2 — systemd's syscall filter and QEMU's `-sandbox` are mutually exclusive
**Symptom.** `qemu-system-x86_64: -sandbox on,...: There is no option group 'sandbox'`, exit 1,
from inside the unit. The identical command line run by hand started fine, and `ldd` shows
`libseccomp.so.2` linked into the binary.
**Cause.** `SystemCallFilter=@system-service` on the unit. Isolated by running the same argv
under `systemd-run` with and without the property: without it, accepted; with it, the error.
**The mechanism, found later and worth the correction.** This entry first said the two were
"mutually exclusive", which is true in effect but does not say why. The maintenance unit was
added afterwards without `SystemCallErrorNumber=EPERM`, and qemu there died instantly with
`status=31/SYS`; the audit record names the syscall — **317, which is `seccomp(2)` itself**.
So qemu calls `seccomp()` at startup to probe for its own sandbox, `@system-service` does not
permit it, and what happens next depends entirely on that one extra line: with
`SystemCallErrorNumber=EPERM` the probe returns an error and qemu quietly concludes seccomp is
unavailable, which is why `-sandbox` reported "there is no option group"; without it, the
kernel raises SIGSYS and kills the process before it has started.
That makes `SystemCallErrorNumber=` load-bearing rather than cosmetic, and both units now carry
it with a comment saying so.
**Change.** `-sandbox` dropped, systemd's filter kept, and the reason written into
`owuivm-launch` where somebody will otherwise "restore" the flag. systemd's is the broader
allowlist (`@system-service` excludes `@mount`, `@swap`, `@module`, `@reboot` and `@obsolete`),
it covers the whole process tree, and `systemd-analyze security` can see it. What is given up
is `spawn=deny`; the vector that mattered there, `migrate exec:` over QMP, is already closed by
vmd exposing typed verbs and never a raw monitor command.
**Not measured:** whether QEMU's own filter would have been narrower in practice. It cannot be
run alongside the other, so there is nothing to compare.
### 59.3 — `ping` can never work here, and it reads as "no internet"
**Symptom.** Anticipated rather than suffered, but worth the entry. `net.ipv4.ping_group_range`
on this host is `"1 0"` — an empty range, so no group may open an unprivileged ICMP socket.
libslirp cannot forward ICMP without one, so `ping` fails in every guest, permanently, on a
machine whose HTTPS works perfectly.
**Change.** A `check_internet` tool that uses curl, and the fact stated **in the tool's own
output** rather than in the agent's system prompt.
**Why there and not the prompt.** The finding restated four times in this file: the model
treats tool output as fact and system-prompt text as advice. An agent that has just watched
`ping` fail three times needs the correction where it reads instructions as findings.
**Result.** `tests/vm_negative_guest.sh` asserts ping fails *while the HTTPS control passes*,
so the day `ping_group_range` changes, the suite says so instead of quietly becoming wrong.
### 59.4 — the guest booted with no DNS, and the reason was cloud-init
**Symptom.** First boot of the base image: every TCP target behaved exactly as designed —
LAN blocked, `1.1.1.1:443` and `8.8.8.8:53` reachable — and `getent hosts archive.ubuntu.com`
returned nothing. `/etc/netplan/` was empty and `resolvectl` showed `10.0.2.3`.
**Cause.** Subiquity does not write `/etc/netplan` itself; it hands the network configuration
to cloud-init, which renders it on first boot. The image deliberately disables cloud-init — it
must not re-run on every boot of a disk restored from a frozen base thousands of times — so
the netplan was never written and the guest fell back to DHCP. SLIRP's DHCP hands out
`10.0.2.3` as the nameserver, which is a forwarder *inside the qemu process* that relays to
this host's resolver, and the sandbox's egress filter denies `127.0.0.0/8` — correctly, because
the host's resolver is a LAN service.
**Change.** The netplan is written directly in `late-commands`, static, with a public resolver.
**Generalisable lesson.** The isolation worked exactly as specified and the result was a
machine that looked broken in a completely different place. A control that denies something
the guest depends on by default will surface as a mystery three layers away; the guest's
configuration has to be changed to match the policy, not just tested against it.
### 59.5 — two permission findings, both silent
**`qemu-img` creates 0644, so a umask of 0007 leaves 0640.** vmd creates the session overlay and
qemu writes it, so the file has to be group-writable. `UMask=0007` cannot produce that: qemu-img
asks the kernel for 0644, and a umask only ever *clears* bits. The result was qemu failing with
`Could not open ...: Permission denied` on a file it could plainly read. Fixed with an explicit
`chmod 0o660`.
**`/Pool` is `0770 root:docker`.** Neither service user could traverse it, so nothing under it
was reachable. The obvious fix — adding them to `docker` — would have handed both of them the
docker socket, which is root. A traverse-only ACL (`setfacl -m u:owuivmd:--x,u:owuivm:--x`) is
the whole of what they actually need: they reach their own datasets and still cannot list the
parent.
### 59.6 — a correction to this file: Open WebUI *does* time out a tool call
**Symptom.** `gateway/nginx.conf` has said since Change 34 that "Open WebUI sets no tool-server
timeout of its own (its `AIOHTTP_CLIENT_TIMEOUT` is unset here), so this is the only clock".
**Cause.** That reads "unset" as "unlimited". `env.py:593-597` sets `AIOHTTP_CLIENT_TIMEOUT` to
**300** when the variable is absent, and `env.py:671-679` falls
`AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER` back to it. `utils/tools.py:1715` uses that for every tool
call. So there have always been two clocks on a tool call, and Open WebUI's was the shorter one.
**Change.** The comment is corrected in place. A VM session needs all three clocks raised
together — `OWUITOOLS_VM_WAIT_SECONDS` in the app, `proxy_read_timeout` on a dedicated
`location = /tools/vm_agent`, and `AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER` on the open-webui
container — with the app's the smallest, so it is the app that answers.
**Process lesson.** The original claim was written from reading one env var's default and not
the fallback chain under it. Nothing had failed because of it: 240 s is under 300 s, so the
gateway always answered first and the second clock was never reached.
### 59.7 — what the agent is not allowed to talk its way past
The previous sub-agent stack on this host was retired for accuracy, and its post-mortem names
two mechanisms: a coding agent that "hallucinated third-party library APIs and wrote
self-confirming tests around them", and agents that, "allowed to run search-only", guessed
from snippets. Both are the same failure — the agent deciding when it is finished, against no
standard but its own prose. Three things answer it, and none of them is a sentence in a prompt:
- **`plan_tasks` must be the first call.** Every other verb is refused until there is a list.
- **`finish` is refused** while any task is open, and refused if nothing was ever run. Marking
a task blocked or dropped *requires* a reason, so "I could not finish" without one is not an
available outcome.
- **The verification command is re-run by vmd** after the agent's last step, in a fresh shell,
and the exit code the user sees is the one from that second run. If it disagrees with the
agent, the agent's summary is still shown — labelled a claim, with the real output beside it.
vmd holds the task list, not the agent, so it cannot be quietly reworded to match what
happened; and vmd assembles the tool result, so the facts come from the side that is not being
checked. That last point is also a prompt-injection boundary: guest stdout reaches the agent,
whose summary reaches the main model, so the summary is fenced, capped and labelled, and
`data:` URIs are stripped — `budget.cap()` passes a bare data URI through untouched and Open
WebUI hands such a result to the model as a **vision input**, which would let a guest choose a
picture for the main model to look at.
**Mitigated, not eliminated:** a self-confirming test still passes when re-run, because it is
still the agent's test. All that is done cheaply is to notice the shape — a verification
command naming a file created during this same session — and say so on the report.
---
**Measured:** both isolation layers proven independently with the other removed; ten must-fail
targets blocked and three must-pass reachable from inside a booted guest; a planted
`core.hooksPath` executes under plain git and does not under `gitea.commit()`; polkit refuses
the maintenance unit to a non-local session and vmd's helper has no verb for it; `screendump`
accepts `format: png` and `input-send-event` exists, both read from this host's live QMP
schema; a session boots and answers on its guest agent in 25.7 s.
### 59.8 — the first three live sessions, and what each of them broke
Driven by the model itself, not by `vmctl`. Each failure is recorded because each one was in a
different layer and only the third run reached the work.
**Run 1 died at step one:** `the model refused the request (HTTP 400): time: missing unit in
duration "-1"`. `keep_alive` was being sent as the string `"-1"`; Ollama takes a *number* of
seconds (negative meaning indefinitely) or a duration *string* like `30m`, and parses anything
quoted as the latter. The report still came back complete, saying exactly that — which is the
first evidence that the "every exit produces a result" rule holds, because the alternative was
a started VM and a blank answer.
**Run 2 died at 4 minutes** with nginx's HTML 504. The dedicated `location = /tools/vm_agent`
had been written and validated but `owuitools-gw` had never been restarted, so the 240 s
default was still in force. Incidentally this proved the cancellation path for real: the api
saw the client disappear, wrote the CANCEL marker, and the machine was stopped six seconds
later. No orphaned VM — which is the bug the previous stack shipped.
**Run 3 completed.** 9 agent steps, 153 s, 4 commands in the machine, one of which exited
non-zero and was recovered from. The verification command it chose is worth quoting, because
it is the behaviour the whole `finish` gate exists to produce:
```
out=$(bash /mnt/project/biggest.sh) && [ "$(printf '%s\n' "$out" | wc -l)" -eq 10 ] \
&& [ "$(printf '%s\n' "$out" | grep -Ec '^[[:space:]]*[0-9]+[KMGT]?B[[:space:]]+/usr/')" -eq 10 ] \
&& printf '%s\n' "$out" | awk '{print $1}' | sort -Cnr && echo VERIFIED
```
Line count, format, every path under `/usr`, *and* that the sizes are actually in descending
order. The supervisor re-ran it independently and it passed; the self-confirming-test note
fired correctly, since the script under test was written in the same session. Committed and
pushed to `https://git.example.com/vmlab/biggest`.
### 59.9 — the admin key that does not need to exist
**Symptom.** Live progress reached the chat by POSTing to
`/api/v1/chats/{id}/messages/{id}/event`, and that endpoint refuses a key whose user neither
owns the chat nor is an admin (`routers/chats.py:1531`). Anyone on this instance may invoke
`vm_agent` and the chat belongs to whoever is talking, so it had to be an **admin** key — the
ability to read every conversation and create users — living in a container in order to be
usable. It was kept out of the agent itself, behind nginx in `owuitools-vmgw`, which bounded
the damage without changing the fact that it existed.
**Cause.** Pushing was the wrong direction. Anything outside Open WebUI that wants to write to
a user's message needs to authenticate as somebody, and the only somebody who can write to
*any* user's message is an admin.
**Change.** The agent writes progress to the job volume it already shares with the api
container (`/vmjobs/progress/<chat-id>.json`), the api serves it on a router that is
`include_in_schema=False` and carries no `operationId`, and a new filter —
`owui-functions/vm_agent_progress.py` — polls it from **inside** Open WebUI and emits the
events itself. There, `__event_emitter__` is already bound to the requesting user's own
session, so nothing authenticates as anyone. The filter's only credential is the owuitools
bearer token, which grants exactly the tools the model can already call.
The event location and the key are gone from `vmgw`; its Ollama locations stay, because
Ollama's unauthenticated `DELETE /api/delete` still needs something in front of it.
**Result.** No Open WebUI credential exists anywhere in the stack.
`tests/vm_agent_asserts.sh` now asserts that as a property rather than a configuration:
vmgw holds no key **and** proxies no `chats/.../messages` path. The progress endpoint answers
200 with the token and 401 without, and does not appear in the OpenAPI document at all — 22
tools, none of them this.
**Two things this cost, and one bug it caused.** Progress is polled every two seconds inside
Open WebUI's event loop rather than pushed. The filter must be installed by hand like the other
four, so a fresh deployment has a working agent and no live view until somebody pastes it in.
And removing `openwebui_default` from vmgw — which looked like tidying, since it no longer
needed to reach Open WebUI — left it on nothing but an `internal: true` network, so it had no
default route to Ollama at all. Its healthcheck passed and every proxied request timed out.
The membership is back, with a comment saying it is for the route and not the destination.
**Generalisable lesson.** "Where does this credential have to live" is often really "which
direction is this call going". Reversing it removed the credential entirely rather than
protecting it better.
### 59.10 — the isolation rule that locked the operator out of their own console
**Symptom.** The maintenance VM's console never connected. The port listened, `query-vnc`
reported `enabled: true, auth: none, host 127.0.0.1, service 5999`, and every client - and a
raw socket - sat there until it timed out. A VNC server greets immediately; this one sent
nothing at all.
**Cause, and it is mine.** `IPAddressDeny=127.0.0.0/8` on the unit. systemd's filter is
described plainly in the man page and I had read only half of it: *"Both ingress and egress
traffic is filtered by these settings. In case of ingress traffic the source IP address is
checked."* The source address of a connection to a loopback port is 127.0.0.1. So the rule
that stops the guest reaching the host through SLIRP's `10.0.2.2` alias is the same rule that
drops the operator's own inbound connection, and there is no way to express "outbound only"
in that mechanism.
Confirmed both directions: with `IPAddressDeny=` cleared and the unit restarted, the server
greets with `RFB 003.008` immediately.
**A second finding on the way:** `systemctl set-property --runtime IPAddressDeny=` on a
*running* unit changes the property and does **not** reattach the BPF program. The first
attempt to test this hypothesis therefore appeared to disprove it. The filter is built at unit
start; testing it needs a restart.
**Change.** The console moved from a TCP port to a **unix socket**, which is not IP and which
the filter therefore does not see. The desktop tool bridges it to a loopback port with `socat`,
from a process running as the operator and outside qemu's cgroup. The unit's
`SocketBindAllow=ipv4:tcp:5999` exception is gone with it: a maintenance boot now opens no
listening TCP port at all, exactly like a session.
**Result.** Both properties hold at once, which was the thing in doubt: the console greets
through the bridge, and `IPAddressDeny` is intact with all nine ranges. `vm_host_asserts.sh`
now asserts the console is a unix socket and that the unit permits no TCP bind, so this cannot
quietly revert to a design that does not work.
**Generalisable lesson.** A filter with no notion of direction will eventually be asked to
allow one direction and deny the other, and the answer is to move the thing that needs allowing
off the filtered transport entirely rather than to weaken the rule. The alternative on the
table - adding `IPAddressAllow=127.0.0.1/32` - would have worked for the console and reopened
the guest's path to every service on the host's loopback, which is the specific hole the rule
exists to close.
### 59.11 — what Windows 11 needs, and the denial that wears a permission error's clothes
**Symptom.** Anticipated rather than suffered, but three of them would each have stopped a
Windows install dead, and two produce messages that point at the wrong thing entirely:
- **No TPM and no Secure Boot** -> "This PC can't run Windows 11". Reads as a hardware
limitation of the VM rather than two missing device models.
- **No virtio driver disc** -> setup reports there are no drives to install to. Reads as a
broken disk rather than a driver Windows has never had.
**Change.** The launcher now branches on the image's name - derived from the disk in
maintenance mode and from the overlay's *backing file* in a session, so a session's
randomly-named overlay still resolves to the right machine. Windows gets
`OVMF_CODE_4M.secboot.fd`, a variable store seeded from `OVMF_VARS_4M.ms.fd` so Microsoft's
keys are actually enrolled, `-global driver=cfi.pflash01,property=secure,value=on` so the guest
cannot rewrite that store, an `swtpm` TPM on a unix socket, and `virtio-win.iso` as a second CD
during installs only. Ubuntu gets none of it.
**And one that was suffered.** `swtpm` refused to create its socket:
`Could not open UnixIO socket: Permission denied`, with nothing in dmesg. Not a file mode -
Ubuntu's AppArmor profile for swtpm permits libvirt's paths (`/run/libvirt/qemu/swtpm/*.sock`,
`/var/lib/libvirt/swtpm/**`) and essentially nothing else, and denies quietly. Fixed through
`<local/usr.bin.swtpm>`, the profile's own extension point, so it survives a package update.
**A second bug found by writing this down.** The maintenance boot picked its media with
`ls -t "$ISOS"/*.iso | head -1` for Windows. With an Ubuntu ISO sitting in the same directory
and a *newer* timestamp, a Windows maintenance boot would have quietly booted the Ubuntu
installer. The glob is now `[Ww]in*.iso` and `fetch.sh` refuses Windows media that does not
match that shape, so the two ends agree rather than one silently disagreeing.
**And a third.** A template unit cannot `Conflicts=` its own other instances, so
`owuivm-maint@ubuntu` and `owuivm-maint@windows` could both come up, fight over the same
console socket and split the host's memory. The prepare step now refuses the second.
**Result.** `tests/test_vm_launch_argv.sh` asserts the whole command line without booting
anything: it replaces the final `exec qemu-system-x86_64` with a `printf`, so every branch
runs for real. Windows has secure-boot firmware, a TPM and a protected variable store; Ubuntu
has none of them; and **both** have a netdev of exactly `user,id=net0,ipv6=off` with no
`guestfwd`, `smb=`, `tftp=` or `bootfile`, no `hostfwd`, and no `-virtfs`, `vfio`, `usb-host`
or `hostdev`.
That last set is the reason the test exists. Those `-netdev` options are handled *inside* the
qemu process, so the traffic never becomes a host socket and neither the cgroup filter nor the
nft table can see it. No filter can catch what no filter can observe; the only defence is that
the string is fixed, and the only way to know it is still fixed is to look at it.
**Not measured:** no Windows machine has been installed. Everything above is the documented
requirement plus an asserted command line, which is not the same as a booted installer.
### 59.12 — the machine was crawling, and it was not nested virtualisation
**Symptom.** The Ubuntu installer was "incredibly slow" to drive. The obvious suspect on this
host is that it is itself a KVM guest, so the sandbox is an L2 machine and every VM exit costs
more — an explanation that is true, unfixable, and in this case wrong.
**Measured instead of assumed.** `query-kvm` reported `enabled: true`, so it was accelerated.
The qemu process sat at 260% CPU on a 28-core host with a load average under 5, so it was not
CPU-starved, and `CPUQuotaPerSecUSec=7s` was nowhere near binding. What was pinned was memory:
`MemoryCurrent` was **exactly** `MemoryMax`, and the cgroup's `memory.events` recorded
**`max 6782`** — it had hit its hard limit 6,782 times in one boot, each hit forcing direct
reclaim. `memory.pressure` showed 38 ms of full stall accumulated while idling at a desktop.
**Cause, and it is mine.** `MemoryMax=10G` for a guest given 8 GiB. The guest's RAM is a
`memory-backend-memfd` with `share=on` — required by virtiofs, and therefore set
unconditionally so a machine with a project share behaves identically to one without — so the
kernel accounts all 8 GiB of it as **shmem**. This host has no swap, so shmem cannot be
reclaimed at all. That left roughly 1.5 GiB for every page of ISO and disk I/O the VM does, and
a 6 GB installer ISO does not fit in 1.5 GiB: each read evicted the last, so the same media was
read from disk again and again.
`MemoryMax` was set to bound a runaway qemu. It was never meant to bound the guest — the
guest's own RAM size does that — and at 10G it was doing the second job badly while not
obviously doing the first.
**First change, and it was the wrong one.** `MemoryMax=14G` on both units, so the page cache
had room. It worked - reclaim went to zero and the stall time fell by a factor of ninety - and
within minutes the *host* was short of memory and started killing background work. One VM had
been handed twelve gigabytes of a 58 GB machine that is also running Ollama. Raising a limit to
make room for something that should not have been inside it in the first place.
**The actual change.** `cache=none,aio=native` on every drive - the disk and both CD-ROMs - so
this VM's I/O is O_DIRECT and never enters the page cache at all. What remains in the cgroup is
the guest's RAM and qemu itself, which is what the limit was always meant to bound, and
`MemoryMax` came back down to 11G. Verified O_DIRECT works on both filesystems in play: ext4
for the base images and ZFS 2.4.1 for the session overlays.
Measured after: cgroup `file` 3.43 GB against `shmem` 3.42 GB - so the page cache charged to
this VM is now a rounding error rather than 2 GB - `max 0`, stall `total=0`, and the host went
from 39 GiB used / 18 GiB available to **35 GiB used / 23 GiB available**, with host pressure
back to zero. Better than the 14G version on both counts at once.
Also `-device VGA` -> `-device virtio-vga`, still 2D: a desktop
repainting through the emulated VGA MMIO path is slow on top of llvmpipe doing the rendering
anyway. Explicitly **not** `virtio-gpu-gl`/virgl — QMP `screendump` reads the console surface
and a device rendering through a GL context does not provide one, which would take the agent's
entire GUI mode with it.
**Result.** Same machine, same media: `memory.events max` **6782 -> 0**, full stall
**37,996 µs -> 0**, and the host better off than before rather than worse. `screendump` still
returns a 1280x800 PNG on virtio-vga, verified.
**Process lesson, and it is the one worth keeping.** The first fix was verified against the
symptom and not against the system: every number I looked at was inside the VM's cgroup, and
all of them improved. The host was never measured until it started killing things. A limit
raised is a limit somebody else now lacks, and "it fixed the thing I was looking at" is not the
same as "it fixed the thing".
**Generalisable lesson.** A cgroup memory limit on a VM is not a limit on the VM. With
`share=on` the guest's RAM lands in the same accounting bucket as the host's page cache for
that VM's I/O, and on a swapless host one of those two is unreclaimable — so the limit is
really "how much page cache may this VM have", and setting it just above the guest's RAM size
means approximately none.
### 59.13 — stopping the unit was pulling the plug
**Symptom.** Noticed while adding an explicit Shut down button, not from a failure.
`systemctl stop` SIGTERMs qemu, which from inside the machine is indistinguishable from
yanking the power lead. For a session that is harmless — the overlay is discarded either way —
but a maintenance boot is the operator editing the base image every future session runs from,
and the GUI's only stop control did exactly that.
**Change.** `ExecStop=/usr/local/libexec/owuivm-powerdown` on both units. It sends
`system_powerdown` over QMP and waits for the guest to stop; systemd then waits for qemu to
exit on its own, up to `TimeoutStopSec`, and still SIGTERMs afterwards if the guest ignored the
button. A courtesy with a deadline, not a new way to hang. It cannot fail: a stop path that
errors leaves the unit in `failed` and blocks the next start, so every path exits 0 — verified
against a bad session id and a missing socket.
**Also:** the GUI is now *Tool Server VM Maintenance*, with Start, Shut down and Console as
three buttons that each mean one thing, rather than one button whose meaning depended on state.
### 59.14 — the freeze was not happening, and nothing said so
**Symptom.** A single line in the journal after a clean shutdown:
`owuivm-maint@ubuntu.service: Control process exited, code=exited, status=1/FAILURE`. Noticed
only because a restart happened to be in front of me.
**Cause.** `owuivm-maint-prepare freeze` computed how much of the disk was allocated with
`du -B1 --apparent-size=0`. `--apparent-size` takes no argument, so `du` exits non-zero; under
`set -euo pipefail` that killed the script at that line - which was **before the
`chmod 0444`**. Every clean shutdown therefore left the base image WRITABLE. The disk was
correct, the checksum was not recorded, the marker was not cleared, and the only evidence was
one line saying a control process had failed.
Two compounding mistakes, both mine. The argument was wrong, and the freeze - the one
safety-critical statement in the file - was placed downstream of code that could abort.
**Change.** The `chmod` now happens first, immediately after the firmware variables are copied
back, and everything after it runs with `set +e` because it is all advisory. `du -B1` without
the flag, which reports allocated size, which was the question. The final log line reports the
mode it actually observes (`stat -c %a`) rather than asserting success.
**Result.** `tests/test_vm_freeze.sh` runs the real script against a scratch directory - the
roots are overridable for exactly this - and checks the resulting mode. Three cases: a disk
with an OS on it freezes and loses its marker; a disk nothing was installed on freezes and
KEEPS its marker, so an interrupted install can be resumed; and a disk freezes even when a
later step is made to fail, which is the case that would have caught the original bug.
**Generalisable lesson.** `set -e` turns every line above the important one into something that
can silently skip it. Order the safety-critical statement first and let the reporting fail if
it wants to - and test the *effect* on the filesystem, not the presence of the line.
**And a test that cried wolf.** `vm_host_asserts.sh` flagged the image as unfrozen while the
operator had it open for maintenance, which is exactly what maintenance is. It now distinguishes
"unfrozen because something is broken" from "unfrozen because you are using it" - a suite that
reports correct states as failures teaches people to ignore it, which is worse than not having
one.
### 59.15 — three faults behind one symptom: "the machine is in an EFI shell"
**Symptom.** The Ubuntu machine came up in the UEFI firmware shell rather than the installer,
with 3.45 GB already written to its disk. And pressing Shut down in the maintenance tool locked
the window - it stopped repainting entirely and looked like a crash.
**Fault 1: the GUI blocked its own main loop.** `subprocess.run("systemctl stop")` was called
directly from the button handler. GTK repaints from the main loop, and stopping a machine now
takes as long as the guest needs to power off - up to `TimeoutStopSec`. So the window froze for
over a minute, which is indistinguishable from a hang. Introduced by me in the same change that
added the graceful shutdown: the shutdown got slower and more correct, and nothing was done
about the thing waiting on it. Start and stop now run on a worker thread and report back through
`GLib.idle_add`; a `_busy` flag keeps the periodic refresh from re-enabling buttons underneath an
operation that has not finished.
**Fault 2: a partial install was declared finished, on size.** The freeze step removed the
install marker once the disk had grown past half a gigabyte, reasoning that a disk with contents
has an operating system on it. An install interrupted partway had written 3.45 GB. The marker
went, the next boot had no installation media, and the disk was neither blank enough to start
over nor complete enough to boot - hence the firmware shell, and no way back except deleting it.
Size cannot distinguish "installed" from "interrupted halfway", and neither can anything else
available from outside the guest. So it no longer tries. **The media stays attached**, and the
boot order does the work instead: the disk is `bootindex=0` and the media `bootindex=1`, the
opposite of the obvious arrangement. UEFI walks its entries in order, so a blank or
half-installed disk falls through to the installer by itself while a finished install boots
itself and ignores the disc still in the drive.
**Fault 3: firmware variables from empty boots were being frozen into the base image.** OVMF
rewrites its boot order on every boot, and the freeze copied that back unconditionally -
including boots that installed nothing. Several restarts in, with the display device changed
from `VGA` to `virtio-vga` and the drives given `cache=none`, the stored entries pointed at
device paths that no longer existed. The copy-back is now conditional on the disk actually
having something on it.
**Result.** Wiped and restarted: the blank disk falls through to the media and boots the Ubuntu
installer, verified by screenshot. `tests/test_vm_freeze.sh` gained a case asserting that a boot
which installed nothing does not rewrite the frozen firmware variables, and its marker case was
inverted - the marker is now *supposed* to survive, and the test says why.
**Generalisable lesson, and it is the same one twice.** Both of the silent faults were a
heuristic standing in for a fact nobody had. "The disk is big, so it is installed" and "the
firmware wrote variables, so they are worth keeping" are both guesses that are right most of the
time and destroy the artefact when they are wrong. Where the fact is not available, arrange
things so the guess is not needed - which here was one boot-order change that made an
interrupted install self-healing.
### 59.16 — planning while it boots, and a wait tool
**Symptom.** Not a fault - a waste. `session_open` blocked until the guest agent answered, so
the sequence was: start a machine, wait twenty-odd seconds doing nothing, then ask the model for
a plan and wait again while it prefills and thinks. Two waits of about the same length, one
after the other, at the start of every session.
**Change.** They now happen at once. `session_open` takes `wait=False` and returns as soon as
qemu is started; the agent's first turn is a planning turn with **only the planning tools
offered**, and the opening message says plainly that the machine is still coming up and it will
be told when to start. When it has a list, the harness calls `session_ready`, which blocks until
the guest answers - and, for a GUI session, until there is actually a graphical session, because
the agent answers at multi-user well before anything is on screen. Then a message goes in saying
the machine is up, with a screenshot already attached, so a desktop session does not spend its
first turn asking for one.
That has a second effect worth as much as the first: by the time there is a machine to use, the
model is loaded and its prompt is prefilled.
**The agent cannot reach the machine early, by two independent means.** The loop does not offer
the guest tools while booting - verified for both interfaces - and `vmd` refuses all thirteen of
them regardless of what was offered, because the loop and the gate could disagree and the gate
should win. The gate also distinguishes "there is no machine" from "the machine is still
booting": the wrong one of those sends an agent off waiting for something that is never coming.
`session_ready` is deliberately **not** a tool. An agent that could ask "is it up yet?" would
ask, repeatedly, and each of those is a model turn spent on nothing.
**And a wait tool.** Previously GUI-only and capped at 60 seconds, which is not a package
install. Now a general tool capped at **20 minutes**, sleeping in two-second steps so a
cancelled session does not sit in it for another quarter of an hour. The cap is not in the
tool's description - that is a sentence paid for on every turn of every session to describe a
case that almost never happens - and is stated in the tool's *output* on the one occasion it
bites, along with what to do about it.
**Result.** `tests/test_vm_boot_overlap.py`: seventeen cases covering what is offered in each
phase, that no guest tool leaks into the planning turn, that the cap exists in software and not
in the prompt, and that the opening message does not claim a machine that is not there yet.
**Not measured:** the actual saving. It should be most of one boot per session, but no session
has been run end to end since the change - the Ubuntu image is still being installed.
### 59.17 — "an explicit act by the operator" was a sentence, not a control
**Symptom.** The Ubuntu base image finished installing and went on reporting *INSTALLING*, and
every boot still attached the installer ISO. The operator asked for it to be switched over. The
comment in `owuivm-maint-prepare` and the note in 59.15 both said that detaching the media is an
explicit act by the operator - and there was nothing for the operator to press. The act existed
only as `rm` on a file in a directory their own account cannot write.
**Cause.** 59.15 removed a guess (disk size standing in for "the install finished") and replaced
it with a decision. Removing the guess was written; giving the decision a home was not. A
decision with no control is not a decision, it is a chore for whoever remembers the path.
**Change.** `owuivm-detach@.service`, a oneshot that removes `<image>.install` and nothing else,
plus a fourth button in the maintenance tool - *Installed — remove disc* - that starts it:
* It is a **unit, not a line in the GUI**, for the reason already written at the top of
`owuivm-maint-prepare`: the tool runs as `operator`, the marker belongs to `owuivmadm`, and a
unix group only takes effect at the operator's NEXT login - which fails confusingly for
exactly as long as the session that installed it lasts. Going through polkit uses the
mechanism already proven for this operator: `50-owuivm.rules` now covers `owuivm-detach@` on
the same `subject.active && subject.user == "operator"` test that starts a maintenance boot.
* `ConditionPathExists=` on the marker, so a second press is a skip and not a failure.
* The button is **visible only while an install is in progress** and **dead while the machine is
running**. Marking an install finished is a claim about a machine that has been shut down and
frozen; a half-installed one still needs its disc, and the disc is the only way back to the
installer. That is the 59.15 fault stated as a widget state.
**Result.** Measured against a real marker: the unit removed it, `owuivmd` was refused
(`Access denied`) while an active session of `operator` was allowed, and rebuilding the launcher's
argv afterwards showed `cdrom drives attached: 0`. Six new assertions in `tests/test_vm_freeze.sh`
cover the unit's existence, that its `ExecStart` removes the marker and nothing else, the
condition, the polkit entry, the GUI control, and that the control is dead while the machine
runs. The last two are greps against the installed tool: a service with no button is the same
sentence in a different file.
**Not measured:** the button has not been seen on screen. The only Chrome the browser tools can
reach is on another machine and cannot open this host's loopback, and rendering GTK4 headlessly
here would have meant publishing a VM control panel on the LAN to photograph it. The logic is
asserted, the unit is proven end to end, and the widget is one `Gtk.Button` appended to a box
that already holds three.
### 59.18 — "where is the unfreeze button?"
**Symptom.** The operator looked at a row reading *frozen* and went looking for the control that
unfreezes it. There wasn't one. Unfreezing happened as a side effect of pressing **Start**
(`ExecStartPre=owuivm-maint-prepare %i unfreeze`) and re-freezing as a side effect of stopping
(`ExecStopPost=... freeze`), so the most visible thing about a base image had no control of its
own and the buttons that did the work were named after something else.
**Cause.** The two decisions were fused when there was only one reason to start a base image -
changing it. There is a second reason, and it is the more common one: **booting the image to see
that it still comes up, exactly as the agent will get it.** Fused, that was impossible to do
safely; the only way to look at an image was to make it writable first.
**Change.** Freezing is now the operator's own verb and starting is another.
* `owuivm-thaw@.service` and `owuivm-freeze@.service`, each one chmod, each polkit-gated like
`owuivm-maint@` and `owuivm-detach@`. The maintenance tool grows a two-state button whose
**label is the state** - *Unfreeze* when the image is frozen, *Freeze* when it is not - dead
while the machine is running, because qemu holds the disk open from the moment it starts and a
chmod then changes what `ls -l` says and nothing else.
* **The disk's mode now decides what boots.** Frozen, `owuivm-launch` creates a throwaway overlay
on `/Pool/vmrun` backed by the untouched base - the same thing `session.py` builds for an agent
- and `close` deletes it. Unfrozen, qemu opens the base itself. So *Start* on a frozen image is
a rehearsal of the agent's own boot, and nothing the operator does in it can survive.
* **`close` no longer re-freezes.** It copies the firmware variables back and records a checksum
when the image was unfrozen, and does **nothing at all** when it was frozen - not even the OVMF
variable write-back, which would otherwise make a look-at-it boot change the image it was
supposed not to touch.
* The safety net that the automatic re-freeze provided moves to where it cannot be forgotten:
**vmd refuses to open a session while a base image is writable**, with a sentence saying so.
That is strictly better than the chmod it replaces, which was silent on the way past and had
already been seen to fail silently (59.14).
**The bug this found in itself.** The first version tested `[[ ! -w "$DISK" ]]`. That asks "can
*this process* write it" - and root can write a 0444 file, so a maintenance boot started by root
took the unfrozen branch and opened the base directly. The test caught it because it runs as root
and the live boot did not, because it runs as `owuivmadm`. Frozen is the **owner write bit** and
nothing else, which is what `session.py`, the desktop tool and the prepare script had all along;
the launcher now says the same thing.
**Result.** Measured on the real image: thaw -> `660`, a session refused with
*"the ubuntu base image is unfrozen, so no session can start"* having created no overlay and
started no qemu, freeze -> `444`, checksum identical across the round trip. A full frozen boot ran
from `/Pool/vmrun/maint-ubuntu.qcow2` (backing file: the base) and left the base byte-identical
with the overlay gone. Six new cases in `tests/test_vm_launch_argv.sh` assert both branches against
whatever mode the host's images are really in; nine in `tests/test_vm_freeze.sh` cover the frozen
boot, the toggle verbs and the units behind them; `== 5a` in `tests/vm_host_asserts.sh` unfreezes
the real image and asserts the refusal, with the restore trap extended so a failed run cannot
leave an image writable.
**One-line consequence worth knowing:** `/Pool` needed a traverse ACL for `owuivmadm`
(`setfacl -m u:owuivmadm:--x`), because the throwaway disk of a frozen boot lives on the same
40 GB-quota dataset as a session's overlay rather than on the root filesystem.
### 59.19 — the share the image had to know about
**Symptom.** "How do I set up the shared workspace on the VMs?" - asked while the hand-installed
Ubuntu image sat unfrozen with `qemu-guest-agent` still not answering.
**Cause.** The project share is one virtiofs tag, `project`, and the guest-side half of it lived
in `images/ubuntu/user-data` as an fstab line written by the autoinstall's late-commands. The
image on this host was installed interactively, so it has no such line - and so do any future
ones built the same way.
The failure that produces is the worst available shape. The machine boots, the guest agent
answers, `run()` works, `write_file()` writes to `/mnt/project` - an ordinary empty directory on
the guest's own root - and the host then commits a project folder in which nothing has changed.
The Gitea link is handed over and the repository is empty. Nothing in the guest, in vmd or in
Gitea reports an error.
**Change.** The session mounts it, every time, over the channel it already has:
```
mkdir -p /mnt/project
mountpoint -q /mnt/project || mount -t virtiofs project /mnt/project
mountpoint -q /mnt/project
```
Idempotent, so an image that does carry the fstab line is one `mountpoint` check; checked rather
than assumed, so a mount that does not take raises with the guest's own words and the sentence
*"nothing written in this session would be kept"*, before the agent is given the machine. A
hand-built image now needs nothing for the share at all.
Also added, because "install it by hand" turned out to be a hundred lines of late-commands that
an interactive install silently skips: `vm/images/ubuntu/by-hand.sh`, the same list written
longhand and idempotent, and `owuivm-guest-run`, which pushes a local script into the running
maintenance guest and runs it. That leaves exactly one thing that has to be typed into a VNC
console - `apt install qemu-guest-agent`, the channel everything else travels over.
**Result.** `tests/test_vm_share_mount.py`, nine cases: no share does nothing, Windows does
nothing (WinFsp mounts it inside the image and there is no command to send from here), the mount
is created, is idempotent, is verified, and a failure stops the session with what the guest said
in it.
**What running it on the real image found.** `by-hand.sh` went in over the guest channel in 45 s
(8.5 s on a second run, which is what idempotent is for), and two of the things it was copying
from the autoinstall turned out to be wrong for **26.04**:
* **`WaylandEnable=false` has nothing to fall back to.** `xserver-xorg-core` is not installed and
`/usr/share/xsessions` is empty, so that line does not choose X - it leaves GDM with no session
to log into, and an autologin into nothing is indistinguishable from a desktop that failed to
start. Removed from `by-hand.sh` **and from `user-data`**, or the next unattended build would
produce the same broken image. The `xorg.conf.d` modeline went with it: the reason for pinning
the mode was that the agent clicks absolute coordinates, and the mode is pinned one layer
further down anyway, by a qemu argv that is a constant. Measured after a reboot:
**1280x800**, from the virtio-vga device, with no display-server configuration at all.
* **Disabling snapd takes Firefox with it.** The autoinstall disables `snapd.service` outright as
a source of background change. On a desktop image the browser *is* a snap, so a GUI agent that
wanted to read documentation would find nothing to read it with. `snap refresh --hold` says the
thing that was actually meant - stop changing underneath me - and leaves the snaps that are
installed working.
**Measured on the finished image:** guest agent 10.2.1 answering 11.6 s after a reboot, a GNOME
**Wayland** session active and autologged in as uid 1000 five seconds later, `gnome-shell`
running, no first-run wizard, and a QMP `screendump` of the desktop at 1280x800 - so the agent's
eyes work on this image and GUI mode has a machine to drive. DNS resolves through
systemd-resolved to SLIRP's forwarder to the host's bind-mounted `resolv.conf`
(`curl https://example.com` -> 200), which is why the static netplan the autoinstall writes was
**not** applied here: both netplan files on this image are NetworkManager-rendered, and adding a
networkd-rendered one to fix something that already works is how a machine ends up with no
network at all.
**Not measured:** the virtiofs mount itself. A maintenance boot is given no share - that is the
one thing only a session does - so the first session on this image is what exercises it.
### 59.20 — the trap I wrote the warning about, in the file that ignored it
**Symptom.** "I clicked Install from ISO on the Windows instance and it errored out." The unit
never started - `journalctl -u owuivm-maint@windows.service` had **no entries at all** - and no
`windows.qcow2` was created.
**Cause.** The desktop tool created the blank disk, copied the firmware variables and wrote the
install marker **itself**, in `/var/lib/owuivm/base`, which is `0775 owuivmadm`. `operator` is a
member of `owuivmadm` in `/etc/group` - `id operator` lists it - and **no process in the running
desktop session has that group**, because the session predates the `usermod` that added it. A
unix group only reaches a process at its next login. Measured: `/proc/<gui>/status` `Groups:`
has `984` (docker) and not `978` (owuivmadm).
That is the exact trap written at the top of `owuivm-maint-prepare` - *"a unix group takes effect
at the NEXT login, so the tool fails in a confusing way for exactly as long as the session that
installed it lasts"* - in the one file that still depended on it. The warning was written while
moving this work into the unit, and this code path was left behind.
**The second bug underneath it.** The tool copied `OVMF_VARS_4M.fd` for **both** images. Windows
needs `OVMF_VARS_4M.ms.fd`, the store with Microsoft's keys already enrolled, or Secure Boot is on
with nothing it trusts and setup refuses the machine. Had the permission problem not stopped it,
the install would have failed later and looked like a hardware requirement. The unit's
`ExecStartPre` has had this right the whole time.
**Change.** `prepare_install()` is gone from the tool, and with it the ISO dropdown: the unit
picks the newest ISO matching the image's own pattern, so a control that looked like a choice was
describing a decision made elsewhere. The row now reports it - *"not installed — will install from
Win11_25H2_English_x64_v2.iso"* - and the button starts the unit. `DISK_SIZE` and `OVMF_VARS` went
too; a copy of a value that lives in `owuivm-maint-prepare` is a copy that will disagree with it
one day.
**Result.** Fifteen new assertions in `tests/test_vm_freeze.sh`: three that the tool contains no
`qemu-img`, no `shutil.copyfile` and no firmware path of its own, and twelve driving the unit's
own `open` verb against a scratch directory for both images - a blank disk that is writable
because an installer has to write to it, an install marker naming the media, and **the firmware
store each image actually needs**, asserted by `cmp` against both candidates so getting them the
wrong way round fails.
### 59.21 — the Windows machine was slow for a different reason than the Ubuntu one
**Symptom.** "The Windows VM is kinda slow - did you only fix the slowness on the Ubuntu machine?"
**Measured first, because the obvious answer was wrong.** The Ubuntu fix (59.12) was host-side -
`cache=none,aio=native` on every drive plus `MemoryMax=11G` - and it is in `owuivm-launch` and the
unit files, so both machines have had it all along. The cgroup of the running Windows installer
said so: `memory.events max 0` (Ubuntu's was **6,782** before the fix), `file` and `shmem` both
7.7 GiB - that is the guest's own RAM and nothing else - and `throttled_usec 347865`, a third of a
second of CPU throttling in three hours. Neither short of memory nor short of CPU.
What it *was* short of was information. qemu sat at **347% CPU** driving an idle installer.
**Cause.** Windows assumes it may be running under Hyper-V and looks for the paravirtual
interfaces that would say so. `-cpu host` alone offers none, so it behaves like a kernel that has
been lied to about its hardware: it spins on locks the hypervisor could have yielded, takes a VM
exit per timer tick and per inter-processor interrupt, and drives its APIC through a trap on every
access. The work is real; it is simply wasted. Linux needs none of this because it has virtio, so
the problem could not appear on the machine that was profiled first.
**Change.** The Windows machine - and only it - gets the enlightenment set:
`hv-relaxed,hv-vapic,hv-avic,hv-time,hv-spinlocks=0x1fff,hv-vpindex,hv-runtime,hv-synic,hv-stimer,hv-stimer-direct,hv-reset,hv-frequencies,hv-tlbflush,hv-ipi`.
Listed explicitly rather than `hv-passthrough`, which hands the guest whatever the host kernel
happens to support today: these images are frozen, and one should behave the same after a host
upgrade as before it. Every flag was verified accepted by this host's KVM before being added.
**And a clock, while the argv was open.** Windows reads the RTC as local time. The host is
`America/New_York`, so `base=utc` leaves the guest four hours in the past - which fails TLS
certificate validation and therefore breaks `pip`, `winget` and anything else that fetches, while
presenting as a network fault. `base=localtime` for Windows, `utc` for Ubuntu.
**Result.** Eight assertions in `tests/test_vm_launch_argv.sh`, both ways round: Windows has the
enlightenments and does not have `hv-passthrough`, Ubuntu is plain `-cpu host`, and each gets the
clock it expects. **Not measured:** the improvement itself. The flags apply at the next start of
the machine and the installer was mid-run; the number to compare against is the 347% recorded
above.
**"Is it just nested virtualisation?" - no, and here is the measurement.** The question came up
again once Windows was installing properly, and it is worth recording the answer because it is the
first thing anyone blames on this host:
* `steal = 0`. L0 gives this machine every cycle it asks for. Oversubscription would show here.
* `kvm_amd npt = Y`. Nested paging is on, so guest memory is hardware-walked rather than shadowed -
that is the expensive half of nesting and it is handled.
* **127,000 VM exits/second** (635,079 in five seconds across 8 vCPUs; 65% `kvm_msr`, 11%
`kvm_mmio`, `kvm_nested_vmexit` **zero**). Exits are where nesting genuinely costs extra, and at
that rate they are single-digit percent of the CPU burn, not the bulk of it.
**What was actually holding it back was our own limit.** `CPUQuota=700%` on a machine with 8
vCPUs: measured at **697% of 700**, with 1,792 throttle events and **103 seconds of CPU withheld**.
One of its eight vCPUs could never run. Raised to 800% - which is exactly what 8 vCPUs can consume
- live with `systemctl set-property --runtime`, and throttling went to zero in the next 30 seconds.
Persisted for maintenance boots only: a session keeps 700% because it runs while the main model may
be answering someone, on eight threads Ollama can also use, whereas a maintenance boot is one
person doing one thing and should have the machine it was given.
**The honest ranking of what made this VM slow,** since three separate things were blamed on each
other: missing Hyper-V enlightenments (76 minutes to not-finish, versus 27 minutes to OOBE), the
overlay sitting on the SMR array after an accidental frozen boot, our own CPU quota, Windows
first-boot servicing (which ends on its own), CPU-side rendering (permanent, minor), and nesting
(real, minor, and not removable from here).
**Two things that are slow for reasons no argv can fix,** worth writing down before they are
reported as bugs: until `virtio-win` is installed, Windows drives *emulated* hardware - most
visibly the display, where `virtio-vga` with no driver is a plain VGA framebuffer and every redraw
is a full repaint - and Defender plus first-boot servicing make the first hour of any fresh
Windows install heavy. `cache=none` also costs an install real time, and it stays: it is what
stopped the cgroup thrashing that made the *Ubuntu* install crawl.
### 59.22 — the test that destroyed the thing it was testing
**Symptom.** A Windows 11 install sat on "Getting ready" for fifteen minutes. It was not stuck: it
was writing to a disk that no longer had a name, on the slowest storage in the house, and would
have lost everything the moment qemu exited.
**Cause - mine, and worth writing down in full.** `tests/test_vm_launch_argv.sh` drove the real
launcher against the **real** directories, and 59.18 added a loop to it that flips each base
image's mode to exercise both boot paths. Run while an operator was installing Windows, it:
1. **chmod'd the live base image** to `0444` and left it there, so the next Start booted *frozen* -
which, since 59.18, means a throwaway overlay on `/Pool/vmrun`. The install went there.
2. **`rm -f`'d that overlay while qemu had it open.** The data survived only as an unlinked inode.
`ln` and `linkat(AT_SYMLINK_FOLLOW)` both refuse to give it a name again - only `O_TMPFILE`
files can be named after the fact - so it was going to die with the process.
3. **Killed the machine's swtpm and overwrote its OVMF variables**, because maintenance boots use a
constant session id (`000...0`) and the test used the same one, so its `pkill -f "swtpm socket
.*$session"` matched the operator's. Journal: `tpm-emulator: Could not stop TPM: Input/output
error`, then `status=1/FAILURE`.
And the slowness that prompted the question was a fourth consequence: `Pool` is four spinning SATA
drives in raidz1 - two of them SMR - while base images live on `/`, which is non-rotational. The
frozen boot had quietly moved a Windows install, which is small random writes with `cache=none`,
from SSD onto SMR.
**This is the second time a test in this suite has damaged a live machine.** The first
(`vm_host_asserts.sh`, 59.x) read `is-active` after starting a unit, saw "active" from the
operator's own boot, and stopped it. Both have the same shape: **a test that shares state with
production and assumes nothing else is using it.**
**Change.**
* The test's copy of the launcher now has its paths rewritten as well as its exec line -
`/var/lib/owuivm/base`, `/Pool/vmrun` and `/run/owuivm` all become directories under its own
`mktemp -d`. The path *rules* are still exercised, because they are rewritten consistently; every
path they resolve to is disposable.
* A distinct session id (`fff...f`), and the swtpm `pkill` matches on the scratch directory rather
than on an id a real boot also uses.
* **It refuses to run at all while any owuivm unit is active**, and says why. Verified: exit 2 with
a Windows machine up.
* The maintenance tool now warns before the same trap can be walked into by hand: a frozen image
with installer media attached reads *"Starting it now would install into a throwaway copy and
lose it. Press Unfreeze first."*
**Result.** All twenty-two cases pass against the scratch tree, and a run leaves
`/var/lib/owuivm/base` and `/Pool/vmrun` byte-identical. **The cost was real and is not recovered:**
the operator's Windows install was destroyed and restarted from nothing.
**The rule this earns:** a test that needs the real launcher gets a copy of the launcher, and a
copy of everywhere it points. If it cannot be made hermetic, it refuses to run while the system it
tests is in use.
**And it was not only that test.** The very next attempt to install Windows failed in
`ExecStartPre` with `cp: cannot remove /run/owuivm/000.../OVMF_VARS.fd: Permission denied` -
because `tests/test_vm_freeze.sh`, run as root, had used the same real run directory and left it
`root:root`. A maintenance boot's session id is a constant, so that path is a constant too, and two
of the three tests that touch this subsystem had independently reached for it. `owuivm-maint-prepare`
now takes `OWUIVM_RUN_DIR` alongside the base, ISO and overlay overrides it already had, the freeze
test uses its own, and `open` checks the directory is writable and names the owner rather than
letting `cp` report it one line later. Verified: both suites now leave `/run/owuivm` containing only
the agent socket directory.
### 59.23 — six calls to read a web page
**Symptom.** Looking anything up in GUI mode cost the agent a screenshot to find the browser, a
click to focus it, a click in the address bar, a `type`, a `key` for Return, and a screenshot to
see the result. Six round trips through a 27B model with thinking on, five of them spent
operating a text field, and every one of them a full prefill.
**Change.** Three tools, two of which never touch the machine at all.
* **`web_search`** and **`read_page`** run on the **host**, in vmd, against the same
`/tools/web_search` and `/tools/fetch_page` the main model calls - same SearXNG, same engines,
same result formatter, same cache, same readability extraction and SSRF guard. Not a second
search client: a copy would be the one nobody remembers to fix. vmd already holds the Gitea
token, so a credentialled call belongs on that side of the socket.
* **`open_page`** puts a URL on the screen in one call. GUI mode only - in CLI mode there is no
desktop, and a tool that cannot work is a turn the model spends discovering that.
**Why the agent cannot search for itself.** SearXNG sits inside netguard's network namespace, on
loopback, behind a default-deny filter. The guest cannot reach it - that is the sandbox working -
and neither can the agent container, whose allowlist is vmgw alone. Measured while wiring this up:
the host cannot reach `172.22.0.11:8080` either (it times out), which is why this goes through the
gateway at the pinned `172.22.0.10:8000` rather than to SearXNG directly.
**The URL is the only thing that crosses into the machine,** and it is validated against
`^https?://` with RFC 3986's character set and then passed to the guest as an **environment
variable**. qemu-ga takes the environment as a list, so there is no quoting to get wrong and no
way for a URL to close the string it is in. A first version of the test demanded that
`https://example.com/$(id)` be *rejected*; that was the test being wrong - `$`, `(` and `)` are
sub-delims and every Wikipedia disambiguation link contains parentheses. Rejecting them would have
broken ordinary use to defend against an injection that cannot happen. The case now asserts the
URL is accepted and that neither script has a slot to format it into.
**Two guest-side details that are not obvious.**
* On Ubuntu the guest agent is root and the desktop belongs to uid 1000, so the launch crosses
into that session explicitly - `runuser` with `XDG_RUNTIME_DIR`, the session bus, and both
`WAYLAND_DISPLAY` and `DISPLAY` because the image may be either - and `setsid`s, so the tool
call returns instead of waiting for a browser to be closed.
* On Windows qemu-ga is **LocalSystem, in session 0**. A GUI process started from there does not
fail, it succeeds *invisibly*, on a desktop nobody is looking at - so the agent would screenshot
an unchanged screen and conclude the page did not load. Until the in-guest broker exists, the
launch goes through a scheduled task marked `/it`, which the task service starts inside the
logged-on session; it is created, run and deleted in one call.
Flags worth keeping: `--no-first-run --no-default-browser-check` (every session is a first run,
because the disk is restored from the same frozen base), `--password-store=basic` (otherwise a
gnome-keyring unlock dialog opens over the page on a fresh profile), and `--start-maximized` (a
known window size, so clicks land where the screenshot says).
**Result.** 41 cases in `tests/test_vm_web_tools.py`. Measured end to end from vmd: a search for
"qemu-guest-agent guest-exec windows localsystem" returned ranked results in one call, and
`read_page` of the QEMU guest-agent docs returned extracted markdown.
**Not measured:** `open_page` against a live guest. Only one machine runs at a time and the
Windows install had the slot; the Ubuntu image is frozen and waiting. The launch script's shape is
asserted, its behaviour is not.
### 59.24 — first real testing, and the five things it found
Both images finished (Ubuntu 26.04.1, Windows 11 25H2 activated, guest agents, browsers, python),
so the whole system was driven end to end for the first time. It works. It also had five defects
that only a real session could have found, every one of them in the seam between "the software
reports success" and "the thing actually happened".
**1. The machine was handed over showing a boot splash.** `_wait_for_desktop` waited for a seat
session, and **gdm's greeter is a seat session** - so it passed before the user was logged in.
Fixed, and it still handed over a splash: measured, `gnome-shell` running, plymouth gone,
`graphical.target` active, and the framebuffer on the Ubuntu splash for **over three minutes**.
The display shows the foreground *virtual terminal*, and the session was not on it: `fgconsole`
said 1, the session's TTY was tty2, `loginctl` agreed `Active=no`. Plymouth had tty1 and nothing
switched away. `chvt 2` put the desktop on screen in under a second. That is also why it looked
intermittent - sometimes gdm switches, sometimes it does not - so the probe no longer waits for
the switch, it **performs** it. Measured after: two clean boots, desktop on screen both times,
ready at 59.0s and 60.7s. The settle after the switch is 3s, not 1s, because mutter repaints the
whole screen and a one-second settle handed over a 3.4 KB near-black frame.
**2. `open_page` reported success for a browser that never started** - twice, two different ways.
* `DISPLAY=:0` made Chromium choose X11 on a Wayland session: *"Missing X server or $DISPLAY"*,
into `/dev/null`.
* Reading the display variables out of **gnome-shell's** `/proc/environ` was worse, because it
looks correct: gnome-shell **is** the compositor, so it never had `WAYLAND_DISPLAY` - it creates
that socket rather than consuming one - and the launch fell back to X11 again.
* The check that should have caught both was `pgrep -u user -f chromium`, which matched **the
launcher's own `runuser ... chromium ...` command line**. The same self-match that a `pkill -f`
walked into earlier in this project.
Now: `systemd-run --user`, which starts it in the user's own systemd instance - where a desktop
session exports its environment, measured on this image as `WAYLAND_DISPLAY=wayland-0`,
`DISPLAY=:0` and mutter's Xwayland `XAUTHORITY`. The unit is **named**, so "did it start" is read
back from `systemctl --user is-active`, which cannot be fooled by a command line. A framebuffer
diff was tried in between and discarded: **the clock in the top bar changes it every sixty
seconds**.
**3. `web_search` worked from a shell and timed out from the daemon.** `owuivmd.service` is
`IPAddressDeny=any` with two allows for Gitea, and the tool gateway was not one of them - so the
cgroup dropped it. A shell is not in the unit's cgroup, which is exactly why testing from one
proved nothing. `IPAddressAllow=172.22.0.0/24` added.
**4. Work could be committed and never published.** `_publish` pushed only `if STATE.commits` -
whether *this* session committed. A session that is cancelled, times out, or loses its daemon
leaves a commit that nothing ever pushes. Measured: `primes.py` was committed locally and
`vmlab/vmtest` did not exist in Gitea. Now the question is `has_commits()` - is there anything to
publish at all - which is the question that was meant.
**5. The agent spent turns discovering what was in the machine.** One guest command at handover
now reports the OS, python, browsers, the desktop user and the project folder's free space, as
**tool output** rather than prompt text, for the reason this stack has now proved three times.
**Result - the whole path, measured.**
* CLI session, 12 steps, **156s**: wrote a word-frequency script, ran it, re-counted independently
with `tr/sort/uniq`, committed, pushed. Supervisor re-ran the verification: passed.
* GUI session, 25 steps, **189s**: searched the web, opened kernel.org with `open_page`,
screenshotted it, wrote what it found, committed.
* Live progress: 4 screenshots published to the filter's file during one GUI run.
* **Through Open WebUI, in a browser**: the status line went *"Booting a ubuntu machine - planning
while it starts"* -> *"1/4 · Run the script and capture the full output"* -> *"The machine has
been destroyed"*, and the main model answered with a real table of the ten largest files under
`/usr/share` (CJK font collections, as it happens) and the commit that holds them. One tool
call, 16.2s to re-prefill 13,692 tokens.
* The ask-first rule fired unprompted on the way: a task saying only "python" was refused with
*"this task mentions something that runs on both machines, and you chose ubuntu"*.
**Not measured:** anything on the Windows machine. `run` there still goes to `/bin/bash` through
`guest.shell`, which is Linux-only, and the project share needs WinFsp before `/mnt/project`
means anything - both are Phase 8 work that the finished image now makes possible.
### 59.25 — a view worth watching, and a session record that outlived its machine
**The view.** The live channel showed one status line. The operator asked for what they would
actually watch: the task list above, and under it the thing the session has. A CLI session gets a
**terminal locked to twenty lines** - the commands it ran and what came back, padded to a constant
height so the message does not jump as output arrives - and a GUI session keeps the screenshot,
because a picture of a desktop beside a transcript of commands that are not on it is two views of
nothing. `events.py` carries `tasks`, `terminal` and `interface`; the filter renders whichever
applies. Em dashes are out of everything the operator reads, here and in the rendered task table.
The terminal is unwrapped on the way through: `run` returns `exit code: 0 / stdout: / ...`, which
is the shape the **model** needs, so the view strips the framing and shows output as output with
`[exit N]` only when it is news. Measured, live:
```
$ ls -la /definitely/not/a/real/path
ls: cannot access '/definitely/not/a/real/path': No such file or directory
[exit 2]
$ bash /mnt/project/vardirs.sh
2.2G /var/lib/
255M /var/cache/
```
**The defect the testing found.** Rebuilding the agent container mid-session killed the loop that
would have called `session_close`. The unit was reaped and `systemctl is-active` said inactive -
and vmd still held the session record, so **every later session was refused** with "a session is
already running" until the daemon was restarted. One interrupted session would have taken the
subsystem out until somebody noticed.
`v_session_open` now asks whether the MACHINE exists rather than whether a record does: a record
whose machine is gone is destroyed, cleared, and logged - `vmd: cleared stale session 70705573...
- its machine was already gone` - and the caller gets their session. Proven by stopping
`owuivm.service` under a live session and opening another without restarting vmd.
**Needs doing by hand:** the Open WebUI filter is installed by pasting its source, so
`owui-functions/vm_agent_progress.py` has to be pasted in again for the new view to appear.
### 59.26 — Windows was never wired up, and a live session found out the hard way
**Symptom.** The operator started a Windows session and watched the agent reboot the machine and
sleep for minutes at a time. Every command came back
`guest-exec: Failed to execute child process (No such file or directory)`.
**Cause.** `Guest.shell()` was `exec(["/bin/bash", "-lc", script])`. Unconditionally. The module
was written as the Linux half with the Windows half left for the broker, and nothing in between
ever said so - so a Windows session came up healthy, answered its guest agent, and failed every
single command. To the agent that does not read as "wrong shell", it reads as a machine that has
stopped answering: so it rebooted it, waited 90 seconds, tried again, waited 120, rebooted again,
waited 180. The repeated-failure guard fired, correctly, at a machine that was never going to work.
**Change.** `shell()` takes the machine it is on: PowerShell with `-NoProfile -NonInteractive` for
Windows, bash for Linux. `Session.guest()` passes `os_name`. And with that one line working,
everything else that had only ever run against Linux surfaced at once:
* **The project path.** `_guest_path` returned `/mnt/project/...` for both. Windows gets
`Z:\...`, because virtiofs is a drive letter there.
* **The desktop probe.** `loginctl`, `fgconsole` and `chvt` mean nothing on Windows; it waits for
`explorer.exe` instead, which is what "somebody is logged in and the shell has drawn" looks like.
* **`check_internet`** was `curl` and `getent`; Windows gets `Invoke-WebRequest` and
`Resolve-DnsName`.
* **Machine facts** had no Windows branch at all. The first attempt at one nested double quotes
through Python into PowerShell and returned **nothing, silently** - the one outcome a
fact-gathering probe must not have. Rewritten as a script in single quotes.
**And then the numbers.** The facts probe still came back empty on a cold boot, and timing each
part separately showed why: the **first** PowerShell in a freshly booted Windows guest takes over
ninety seconds - Defender inspecting it, .NET warming - while every one after it takes five to
nine. So the shell is now warmed during the boot window, where the agent is writing its task list
and nobody is waiting. Measured after: cold Windows session **ready in 115s with complete facts**,
against 32s for Ubuntu CLI and 60s for Ubuntu GUI.
**What the facts now say, which is the point:**
```
python: not on PATH for this account
running as: SYSTEM (the guest agent is LocalSystem, not the desktop user)
project folder: NOT MOUNTED - nothing you write will be kept. Say so rather than working around it.
```
**Two things the Windows image still needs, both measured from inside it.** `VirtioFsSvc` is
installed and **Stopped**; starting it fails with *"A timeout was reached (30000 milliseconds)
while waiting for the VirtIO-FS Service service to connect"*, and
`Test-Path 'C:\Program Files (x86)\WinFsp'` is **False** - the viofs kernel driver is there, the
user-mode half it needs is not. And Python is installed for a user rather than for the machine, so
the account the guest agent runs as cannot see it. Until both are fixed a Windows session can run
PowerShell and keep nothing.
**Still not true on Windows:** the guest agent is LocalSystem in session 0, so this is not the
"normal elevated PowerShell" the design asked for. Scripts and their output are exactly right;
anything that depends on being the logged-in user is not. The in-guest broker is what closes that,
and it is still unwritten.
### 59.27 — the live view, as the person watching asked for it
Three corrections from someone actually watching a session: the task list and the terminal should
be **fully visible** rather than something to scroll through, a long line should be **cut** rather
than put a horizontal scrollbar under the box, and the code block should not appear in GUI mode at
all. So the embed is sized to its content with no height of its own, every terminal line is its own
element that clips at the edge, the task list wraps instead of scrolling, and the twenty rows are
always present so the box does not change size as output arrives. Em dashes are out of everything
the operator reads.
### 59.28 — the Windows image finished, and then saved so it need not boot
Two pieces of work, in this order because the second depends on the first: a saved machine is
only valid against the exact image it was taken from, so the image had to be finished first.
**The image.** `WinFsp` was missing, which is why `VirtioFsSvc` was installed and Stopped and
`Z:` did not exist - the viofs kernel driver was there, the user-mode half it talks to was not.
Installed 2.1 with `INSTALLLEVEL=1000` (the default level takes the driver only and the service
still fails to connect), service set to Automatic. Python was installed for a user, invisible to
the account the guest agent runs as; reinstalled with `InstallAllUsers=1 PrependPath=1` as 3.14.7,
matching Ubuntu's 3.14.4 line so the same script has one fewer reason to behave differently on the
two machines. The timezone was **Pacific** while the host RTC is `base=localtime` on an Eastern
host, so the guest clock was three hours out before anything resumed anything; set to Eastern, with
`w32time`'s phase-correction limits removed so a large correction is allowed at all, and Windows
Update's automatic install and reboot turned off. Verified from a real session: `Z:\` mounts, a
file written in the guest appears on the array, python answers, the clock is within a second.
**The saved machine.** `vmctl snapshot --os windows` boots the image once, lets it settle, and
writes its memory and its disk. A session then branches from that instead of booting:
| | cold | warm |
|---|---|---|
| Windows session ready | 91 - 115 s | **30 s** |
| first PowerShell in the guest | 90 s+ | already warm, in the saved memory |
| saved state | - | 3.92 GB, written in 14.6 s |
| saved disk | - | 163 MB (the boot's own writes) |
**Four things that had to be got right, each measured rather than assumed.**
1. **The share cannot be in a saved state.** `migrate` refuses outright: *"Migration disabled:
vhost-user backend lacks VHOST_USER_PROTOCOL_F_LOG_SHMFD."* So the snapshot is taken with no
share, and the share is hot-plugged after the resume. q35's root complex is not hot-pluggable,
so the argv now always carries a `pcie-root-port` - and the share goes into it on **both**
paths, at the same address, because a drive letter that moved between sessions would be worse
than a slow boot.
2. **`exec:` cannot be the transport.** It fails with *"Unable to write to command: Broken pipe"*:
the unit's syscall filter stops qemu spawning the helper, which is the sandbox working. `file:`
needs nobody - and then a second problem, that qemu creates its files 0600 as the sandbox user,
so vmd cannot read what it just asked for. Copying four gigabytes was one answer and letting
qemu write into the saved-machine directory was another; both are worse than passing a
descriptor vmd opened itself, which is what it does.
3. **A saved state is only valid against one disk**, down to the page cache. Resume it against a
changed image and nothing reports an error - the guest simply runs with a filesystem in memory
that disagrees with the one on disk. The freeze already records a checksum, so the manifest
records which one the state came from and `warm_set()` compares them. Verified by falsifying
the manifest: the saved machine is refused and the session boots cold, 96.6 s.
4. **It must never be load-bearing.** A saved machine that will not load becomes a cold boot, not
a failed session. Verified by truncating the state to 200 MB: the resume failed, vmd booted
cold in 109 s and told the caller *"the saved machine could not be resumed, so this one was
booted from cold. Nothing about the machine differs."*
**The clock, which is the reason the operator asked for any of this.** A resumed machine believes
it is whenever it was saved - measured at **26 seconds behind** on the first warm session, and it
would be days for a snapshot left over a weekend. Windows minds most: a wrong clock fails TLS, so
every download breaks in a way that reads as a network fault. `guest-set-time` runs on **every**
session, warm or cold, before anything else touches the guest - and it is **read back and checked**
rather than assumed, which is how the first version came to be 26 seconds out while reporting
nothing: the share service had just restarted, the agent was busy, the call failed and the error
was swallowed. Now: `clock: set, within 0.0s of the host`, measured skew -0.8 s.
**And two more Linux-shaped assumptions the first real Windows session found.** The supervisor's
verification prelude was `cd ... 2>/dev/null || cd /;` for both machines, and PowerShell has no
`||` - so a session whose work was entirely correct was reported to the user as a *claim*, because
the re-run failed on a parse error in vmd's own wrapper. And the agent lost two turns wrapping
commands in `powershell.exe` when it was already in PowerShell, and using `&&` in 5.1. The first is
fixed; the second is now in the machine facts, where this stack has three times proved a model
reads it as fact. Measured after both: 9 steps, 129 s, four commands, **all exited 0**,
verification passed on the supervisor's re-run.
**Not done:** Ubuntu has no saved machine. It boots in 32 s and the agent's planning turn hides
most of that, so the saving would be a few seconds of somebody's attention; the machinery is the
same verb (`vmctl snapshot --os ubuntu`) if that changes.
### 59.29 — six real questions, and the three that never reached the machine
**Symptom.** Everything in 59.17-59.28 was proved a verb at a time, by me, knowing what I wanted
to happen. The operator asked for the other test: six prompts written as a real person would
write them, with no hand-holding, put into the chat and left alone.
**Result, the short version: the subsystem passed and the routing did not.** Three of the six
used the machine and all three worked. Three did not use it, and two of those should have.
| # | Asked for | What happened |
|---|---|---|
| 1 | a Python renamer that breaks on odd filenames, OS unstated | chose Ubuntu itself, built 14 adversarial filenames with a deliberate mtime collision, **7 of 8 tasks done and 1 blocked with a real finding** - the script is not idempotent, a second run re-stamps every file. Root cause named. Committed and pushed. |
| 2 | a PowerShell service audit, "make sure it genuinely works" | Windows warm start, **6/6**, supervisor re-ran the script itself and it passed on real service data. The answer flagged that `Automatic (Delayed Start)` needs `-like`, not `-eq` - a subtlety that only running it revealed. |
| 3 | "show me what the Ubuntu desktop looks like out of the box" | **never touched the machine.** Three `web_search`, three `fetch_page`, and the sentence *"I can't drop a live screenshot into this chat"* - written while holding a tool that boots that exact desktop and photographs it. |
| 4 | "does `Restart=on-failure` restart a clean exit? I want what it actually does, not what the docs imply" | **read the man page.** Correct answer, wrong method, and the prompt had said so in advance. |
| 5 | "can the VM reach my NAS at 192.168.1.50?" | **zero tool calls.** Reasoned from its prompt that the machine has no route to the LAN and declined to spend a session proving a guaranteed timeout. Right call - asserted rather than demonstrated, which here is the cheaper of the two. |
| 6 | a Flask stand-in service, "I want to know it actually starts" | `vm_agent` **refused** the ambiguous OS - *"CANNOT DO THAT YET: this task mentions something that runs on both machines"* - the model asked, I answered Ubuntu, it called again. **7/7**, pip install, background server, real curls, determinism re-checked, server killed, committed. The ask-first rule exactly as designed. |
**Cause.** Not a fault in the subsystem - every part of it that was reached worked, including the
parts hardest to trust: blocked-with-reason, the supervisor's own re-run, the ask-first refusal,
three new Gitea repos, and a clean host afterwards (no qemu, no overlays, both images frozen).
The fault was in **what the model had been told the tool is for**, and it was my wording. The
docstring said, in as many words, do not use this to find out *what software is, how it is
configured, or what it looks like*. That sentence is what lost test 3: "show me the desktop" is a
"what it looks like" question, and I had labelled it out of scope for the one tool that could
answer it. Test 4 failed for the neighbouring reason - the guidance keyed on the user asking for
proof, and "I want to know what it actually does, not what the docs imply" is a request for proof
that does not use any of the words I had listed.
**Change.** The line is redrawn where it actually falls - not *what kind of thing is being asked
about*, but *whether a document can answer it*:
* USE IT when the answer has to be true of a real machine rather than of the documentation - and
explicitly: **if an honest answer would have to begin "the documentation implies" or "it
should", that is the sentence you cannot write. Run it.**
* DO NOT USE IT when a document already answers and nobody asked for proof.
* "what it looks like" moves out of the refusal and into `gui`, which is where it belonged:
`gui` is the slowest thing available and is for when the answer has to be **seen** as it
appears on screen, or the work can only be done by clicking. *"Show me what this looks like"
is what it is for."* Plus one line aimed squarely at test 3's failure: do not tell someone you
cannot show them a screen without first asking whether this is the tool that can.
**Result.** Test 4's prompt re-run verbatim against the new docstring: the model called
`vm_agent` and wrote itself an empirical task - including a guard that stops the test if `systemd`
is not PID 1, because the result would otherwise be meaningless. The same wording is in
`prompts/system-prompt.md`, which is **not deployed**: the live system prompt is baked into the
Ollama model, so it needs a rebuild to take effect. The docstring is served live and is doing the
work on its own.
**Two smaller things the round recorded.** The model writes `/mnt/project` into Windows task text
and the agent silently corrects it - now covered by "write the job as instructions, not paths".
And the live task list and terminal are cleared when a session ends, so they exist only while
running; the final task table survives in the tool result, the terminal does not.
### 59.30 — asked before the machine starts, and a box that was never the size it looked
**Symptom.** Two unrelated complaints from the same session, both about the same minute of the
user's time. A `vm_agent` call takes minutes and destroys its machine afterwards, so a job sent
with the wrong wording costs the whole session - and there was no point at which the person who
asked could see what was actually being sent. And the live view, rebuilt in 59.27 explicitly so
that nothing would need scrolling, **still had a vertical scrollbar**, in one box rather than
the two that were asked for.
**Cause, first half.** Nothing structural - the feature did not exist.
**Cause, second half, and this is the interesting one.** Two separate bugs, neither of which
anything could see from the outside.
A bad edit had left **a second copy of `_embed`** in the class. Python keeps the last definition
of a method, so the file parsed, imported and ran perfectly while silently using the old
single-box version; the new two-box one sat above it as unreachable code. Nothing failed and
nothing warned. The only symptom was the wrong thing on the screen, which is exactly the symptom
that gets blamed on the CSS.
Underneath it, the height. 59.27 says the embed is "sized to its content with no height of its
own", and that was **an assumption, not a measurement**. Read at source, Open WebUI sizes one of
these frames two ways: on load it reads `contentDocument.body.scrollHeight + 20`, and it listens
for `postMessage({type: "iframe:height"})`. The first **cannot work here**. The frame is sandboxed
without `allow-same-origin` - the embeds renderer passes `iframeSandboxAllowSameOrigin ?? false` -
so `contentDocument` throws a cross-origin error, and the read sits inside a bare `catch {}`. The
height is then never set at all and the frame keeps its stylesheet default of **150px**. Measured
in the browser: a seven-line task list needs 138px and a twenty-line terminal 364px, and both sat
at 150 with a scrollbar.
**Change.** The tool asks first, and it asks from inside itself. `_ask_before_starting()` writes
the job to a file, the filter running inside Open WebUI picks it up and puts **one dialog** to the
person - empty and OK runs it, typing what to change hands their words back to the model, Cancel
starts nothing. It is done in the tool rather than in the prompt for the reason this repo has
proved three times: the model is simply blocked in its call and cannot skip it, ask badly, or
report the answer unfaithfully. A dialog nobody reads within 20 s is not a veto - the job runs,
because a confirmation that nobody can see must not be able to stop work.
For the view: the duplicate method is gone, and the frame now **measures itself and posts its own
height**, which is the route that needs no same-origin and which `allowScripts` (hard-coded on for
embeds) permits. It measures the **wrapper element**, not the document - a third trap, found by
measuring: `html{overflow:hidden}` makes `documentElement.scrollHeight` report the frame's
*current* height whenever the content is shorter, so a box measured that way grows and never
shrinks again. A `ResizeObserver` re-reports, which is what catches a screenshot that is still
decoding when the rest of the box is ready, and a zero is never sent because a frame in a
background tab has no layout and measures 0.
**What the dialog shows, and why it is not the job.** The job the model writes is an instruction
sheet for another machine - numbered steps, exact commands, the wording that stops an agent
improvising. Real ones measured 1,900 to 2,600 characters, and the first version showed them
whole. Two things were wrong with that. Nobody reads an instruction sheet to decide whether to
press OK; and Open WebUI does not scroll that dialog, so a job that tall pushed OK and Cancel
**150px below the bottom of an 800px window** and the question could not be answered at all.
So the dialog gets a short summary of what the job will *do*: the opening line, then the head of
each step, six of them and a count of the rest. Measured: 1,861 characters became seven lines.
**It is built from the job, not written by the model.** This is the one screen where a summary
that drifts from the text is a real problem - the person approves the summary and the machine is
sent the job. A summary pulled out of the job's own headings cannot describe work that is not in
it, and asking the model for one would be both a second thing to trust and more work for it to
do, which is the opposite of what was asked for. A test asserts that every word in the summary
appears in the job.
Four things had to be measured rather than assumed, each found by looking at the rendered dialog:
* **A bulleted list under a step is not a step.** A real job's bullets were the *data* the steps
operated on (`- /tmp/logtest/recent.log -> mtime = now`), and counting them pushed every actual
step out of the summary. Numbered lines and `STEP` headings win; bullets are used only when
there are none.
* **Sentences end at `.!?` and nothing else.** Splitting on the colon too turned
`Run: ps -p 1 -o comm=` into the bullet `Run:`, which says nothing - the useful half is always
on the far side of the colon.
* **An abbreviation is not a full stop.** `e.g.` and `(i.e.` cut bullets off mid-word until the
punctuation around the token was stripped before checking it.
* **A plain newline is a soft break in markdown.** Consecutive lines were joined into one
paragraph and two steps ran together on a single wrapped line. A `- ` list item at column 0 is
the one form the renderer keeps on its own line: indent it and it becomes a nested block, use a
bullet character of your own and it is not markdown at all and gets joined like any other prose.
A step that is only a label - `Report:` - takes the line under it, because a bullet reading just
`Report:` spends a line and says nothing.
**Result.** All three paths driven through a real chat, not asserted:
* **Accept** - dialog shown, empty + OK, job dispatched, machine booted, seven tasks run, the
agent correctly reported that `-mtime +7` keeps a file at exactly 7 days *and* at 7 days 12
hours and deletes at 8, the non-`.log` control survived, and the nested file did not. Machine
torn down, `owuivm.service` inactive, no qemu left.
* **Refine** - typed *"Do not install anything. Only check what is already there, and also print
the exact python3 path"*; the model rewrote the job to say exactly that and **asked again**. No
machine was started in between.
* **Cancel** - no machine started, no job written, and the model closed the turn with four words
rather than arguing with it.
**And then the view killed a session, which is the part worth writing down.** A run finished all
six of its tasks, verified them, and returned nothing at all. The agent loop had died at the last
step:
```
File "/app/loop.py", line 464, in _terminal_for
return [f"$ {str(arguments.get('verification', {}).get('command', ''))[:300]}"]
AttributeError: 'str' object has no attribute 'get'
```
`finish` is declared as taking `verification` as an object with a `command` in it. The model sent
a string. The renderer for the **live terminal** - a cosmetic feature, added so somebody watching
the chat can see what is happening - called `.get` on that string, and the exception came back out
through the agent loop and threw the finished work away.
The narrow fix is one `isinstance`. The real fix is the rule that was missing: **the picture of
the session has no bearing on whether the work succeeds, so it does not get a vote.** Every
renderer now returns `[]` rather than raising on anything the model can send, and the call itself
goes through `_show()`, which swallows whatever gets past that and logs it at debug. One malformed
argument costs a line of output, not a session.
`tests/test_vm_live_view_safety.py` covers it: the exact call that crashed, every renderer against
eight shapes a model might send in place of an object, a deliberately exploding renderer *and* a
deliberately exploding emitter, and a source-level case that fails if the loop ever calls
`ui.terminal(...)` outside the guarded path - which is how the first one got in. Reverting the
`isinstance` makes the first case fail, so the test is known to catch the original bug rather than
merely to pass.
Re-run afterwards, same prompt: **8/8 tasks, status Done, no loop errors.**
And the view, measured live in the browser during a session: **two iframes**, heights **86px** and
**364px**, neither at the 150px default, and no horizontal scroll on the page.
`tests/test_vm_live_view.py` covers all of it - including a case that fails if any method in the
filter is ever defined twice again, and one that fails if the height is measured from the document
instead of the content.
### 59.31 — four things wrong with the last thing the user sees
Four complaints from one real session, none of them about whether the work was done.
**1. It wrote the script, handed it over untested, and waited to be asked.** The reply to
"write a powershell script that will pull system info" was a script; only after "yeah go ahead
and test it" did it reach for a machine. The confirmation dialog (59.30) changes the economics
here and the wording had not caught up: the user is shown the job before anything starts, so
*proposing* a verification costs them one click. The docstring now says so directly - **when you
have written a script the user is going to run, call this in the same reply** - on the grounds
that an untested script is a guess however good it looks.
**2. Reopening the chat mid-session showed no progress at all.** Two defects, one real and one
worse.
The real one: the watcher emitted **only on change**. These updates are pushed, so somebody who
reloads the page or opens the chat on a second device joins partway through and receives only
what comes next - and a session holds one step for minutes at a time (`Waiting for the machine
to finish booting` held across four consecutive polls, measured). A viewer arriving just after a
change sits in front of an empty message for the length of the step and concludes nothing is
happening. The whole view is now re-sent on a 10-second heartbeat as well; `replace: true` makes
that idempotent, so the cost is one small message per tick and reattaching fixes itself.
The worse one: the watcher's `finally` block wiped the view and announced **"The machine has
been destroyed."** - unconditionally. That block also runs when the watcher is cancelled or hits
its own `max_minutes` deadline, and in those cases the machine is still running. The watcher
stopping is not the same event as the session ending, and only the latter now says so; the other
says "No longer watching this session."
Verified from the logs rather than assumed: a full page reload at 20:13:37, about 25 seconds
before that session ended. `statusHistory` (33 entries) and `embeds` both persist through
`get_event_emitter`, and events are broadcast to `user:{user_id}` rather than to a socket
session, so a reconnected browser is in the right room - which is why the heartbeat is sufficient
and why nothing needed to change on the Open WebUI side.
**3. Asked for a link to download the script, given a link to a repository.** Those are different
things: one saves a file, the other opens a page to navigate. The report now lists every
committed file with the address that serves it (`/raw/branch/main/<path>`), and says plainly not
to offer a repository URL in place of one. `git ls-files` rather than a directory walk, because
the walk would also list whatever the guest left uncommitted and a link to that is a 404 with the
operator's name on it. Measured: `http 200, 1346 bytes, text/plain`.
**4. The repository said nothing about what it held.** Every repo was labelled *"Created by the
owuitools VM agent for project 'x'"*. The operator's words: it "just has to accurately describe
what the app does briefly - not sell it to me". So it is **generated from the job**, for the same
reason the confirmation summary is (59.30): a description that is generated cannot be a pitch and
cannot describe work that was not asked for.
The first version was too literal - it set *"On the Ubuntu machine, write a small Python script
called wordcount.py that takes a filename and prints the..."*, which is the instruction, verbatim
and cut off. A job opens by addressing the machine and telling it to build something; a
description labels the thing that was built. Both leading clauses are now stripped, and only
verbs that CREATE are removed, so a project whose job was to *test* something is still described
by that word. Measured on the next real session:
```
Small Python script called sumcsv.py that reads a CSV file and prints the total of a named numeric column
```
An existing description is never overwritten unless it is the old placeholder, so a repository
somebody has labelled by hand keeps its label - and repositories created before this change keep
whatever they had.
**Result.** Two full sessions driven end to end against the live stack: 6/6 and 5/5 tasks,
supervisor verification passed, direct links present and fetching, description correct on the
repository created after the fix. `tests/test_vm_git_hardening.py` covers the links and the
description; `tests/test_vm_live_view.py` covers the heartbeat and the honest close.
**Not verified:** the reopen itself. The browser tooling disconnected partway through this work,
so the fix is reasoned from the emitter's source and the request log rather than watched
happening. The two defects behind it are both real and both fixed, but nobody has yet reloaded a
chat mid-session and seen the boxes come back.
### 59.32 — the agent and its supervisor were standing in different directories
**Symptom.** A Windows session was asked to write a PowerShell script, test it and keep it. It
did all three, correctly, and the session was reported as a failure. The model read the failure,
worked out what had happened, and spent **a second entire session** redoing the same work -
about four minutes of machine time to move one file.
**Cause.** The supervisor's re-run started in the project folder. The agent's own `run` did not:
it inherited whatever directory the guest agent happened to start in, which on Windows is
`C:\Windows\system32`. So `Set-Content Get-SystemInfo.ps1`, `Get-Content .\Get-SystemInfo.ps1`
and running the script all agreed with each other and all passed - and then the supervisor
re-ran the verification one directory away and found nothing.
Two copies of "where does a command run" is exactly how they came to disagree. It had been
written once in `verify.py` and never at all in `vmd.py`.
**Change.** One `working_directory_prelude(session)`, called by both. It is the project folder on
both machines, which is also the only place work survives - a file written anywhere else is
deleted with the disk. `tests/test_vm_working_directory.py` asserts both callers use it and that
the directory is decided in exactly one place.
**Result.** The same case re-run, with the agent deliberately told to use a relative path: the
supervisor's `powershell.exe -NoProfile -ExecutionPolicy Bypass -File Get-Uptime.ps1` **passed,
exit 0**, first session, no retry.
---
Three smaller things from the same run.
**The reply pasted the whole script back into the chat.** It had already been given a download
link, so the code was a wall to scroll past to reach the one line the user wanted. Both the
docstring and the prompt now say: give the link, say what it does and what the test showed, and
quote a few lines only where the point is about those lines.
**The repository description came out as a shouted instruction** - *"IMPORTANT: create
Get-SystemInfo.ps1 INSIDE YOUR PROJECT FOLDER..."* - because the description is generated from
the job (59.31) and that job was the corrective re-prompt. The directory fix removes the cause;
`IMPORTANT:`/`NOTE:`/`WARNING:` are now stripped like the other labels in case a job opens that
way again.
**The download link 404s, and that is authentication rather than a broken URL.** Measured on the
exact link from the chat: anonymous **404**, authenticated **200, 3,976 bytes**. The `vmlab` org
is deliberately **Limited** visibility - public repos inside it, readable by any signed-in Gitea
user and invisible to everyone else - which is the arrangement this project was asked for. A link
to it therefore only works in a browser that is signed in to `git.example.com`. Recorded rather
than changed: making it work anonymously means making every `vmlab` repository readable by
anyone on the internet, which is the operator's call and not a bug fix.
### 59.33 — a release per session, and what actually downloads
**Symptom.** A link to the file in the repository opens the source in the browser instead of
saving it. The operator's decision was to publish a **release at the end of every session** and
hand over the releases page instead.
**What I got wrong first, and measured second.** I assumed release assets download. They do not:
Gitea decides inline-or-attachment by MIME type, so a `.py` or `.ps1` comes back
`text/plain; Content-Disposition: inline` whether it is a raw file URL *or* a release asset, and
no query parameter flips it. Measured on all three.
The one thing on that page served as an attachment is the archive Gitea builds for the tag -
`/archive/<tag>.zip` returns `Content-Disposition: attachment`, which is the "Source code (zip)"
link. So the releases page is the right thing to hand over for two reasons rather than one: it
lists the session's files by name, and it carries the one link that saves to disk. The report says
exactly that, rather than claiming the files download.
**Change.** After the push, and separately from it, vmd creates a release tagged `v1`, `v2`, ...
and uploads the committed files as assets. The tag number is counted from what is already
published rather than held anywhere, so it survives a vmd restart; only one session runs at a
time, so there is nobody to race with. Assets are capped at 10 files and 25 MB each, taken from
`git ls-files` so nothing uncommitted is ever offered, and a nested path becomes a flat name
because a release has no folders. The upload is multipart on the stdlib, with a random boundary -
a fixed one appearing inside a file would corrupt the upload.
**It cannot take a session down.** By the time it runs, the work is committed and on the server. A
release that fails to publish is a missing convenience, and the report says so - "the work is
committed and pushed; only the download page is missing" - rather than turning a session that did
its job into one that reports a failure.
**Result.** Two consecutive sessions against the same project: `v1` with `sample.txt` and
`wordcount.py`, then `v2` with `README.md` added - one release each, holding the files as that
session left them. `/archive/v2.zip` returns `Content-Disposition: attachment`.
**The 404 was never a bug.** The `vmlab` org is Limited visibility by design; the operator was
signed out. Confirmed with them, and **nothing was made public**.
### 59.34 — it spent a whole reply writing the job instead of sending it
**Symptom.** "Build a GUI PowerShell that acts as a Windows troubleshooting toolkit." The model
answered with four sentences of intent - *"I'm going to write it and then have it launched and
clicked through on a real Windows machine before handing it to you"* - and then **nothing
happened**. No tool call, no machine, no script.
**Cause, from the usage record:** `eval_count: 32768`, which is exactly the `max_tokens` set on
the model in Open WebUI. 32,768 output tokens produced 398 characters of visible content and
`tool_calls: []`. The model had spent its entire output budget writing the PowerShell GUI *into
the task argument* of the `vm_agent` call, hit the ceiling mid-argument, and the call was never
emitted at all.
The instruction told it to verify a script it had written. It read that as "write the script,
then hand the finished thing over" - which is both the wrong order and the wrong division of
labour, and at GUI scale it does not fit in one reply.
**Change.** The handoff moves to the front, in the docstring and in the prompt: when the user
asks for something that has to be built and run, `vm_agent` is the **opening move of the reply**.
Do not design it, do not write it, do not work out the approach - the agent does all of that on
the machine, where it can run what it writes. The `task` is the user's request restated plus its
constraints and a sentence on what "working" means; the `task` field now says **NOT an
implementation - do not write the code here, the agent writes it**, and the docstring carries the
measurement above as the reason.
**And a correction to this log.** 59.29 and 59.30 both say the system prompt is baked into the
Ollama model and therefore cannot be deployed from here. **That is wrong.** It lives in
`params.system` on the `qwen3.8:latest` row of Open WebUI's model table, is read per request
(`routers/ollama.py` calls `Models.get_model_by_id` on every completion), and takes effect on the
next message with no restart. The deployed copy had drifted 3,700 characters behind
`prompts/system-prompt.md` - every routing fix from 59.29 onward was live in the tool schema and
absent from the prompt. It is now deployed, byte-identical to the repo, with the previous version
kept. Diffed before writing: four lines reworded and fifty-one added, all inside the two
`vm_agent` sections, nothing else touched.
**Not verified:** the behaviour change itself. The browser tooling is disconnected, so I could
not put the prompt back through a chat and watch it hand off. The schema and the prompt are
confirmed live and correct; whether the model now calls first needs one message through the UI.
### 59.35 — four machines, four prompts, and a line that proves it is alive
**Symptom, and it is the one that gives the game away.** A **Windows** GUI session wrote itself
the task *"Check machine readiness: PowerShell version, display resolution, /mnt/project
accessibility"*. `/mnt/project` is the Linux path; on Windows the project folder is `Z:\`.
**Cause.** All four kinds of session - ubuntu/windows by cli/gui - were given the same prompt and
the same tool descriptions, which named both machines and left the agent to work out which half
was about it. `prompt.md` said "`/mnt/project` persists; everything else is destroyed", full
stop. The `run` tool said "a root bash login shell on Ubuntu and an elevated PowerShell on
Windows". This stack has three times proved the model reads tool text as **fact** rather than as
something to reason around, and that is exactly what it did.
**Change.** A session is now told about the machine it is in and about nothing else. The prompt
is assembled from `prompt.md` plus `prompt.{ubuntu,windows}.md` plus `prompt.{cli,gui}.md`, and
the tool descriptions are built from a `MACHINES` table. So:
| | project folder | `run` is | screen tools | lookup suite |
|---|---|---|---|---|
| ubuntu/cli | `/mnt/project` | a root bash login shell | no | yes |
| ubuntu/gui | `/mnt/project` | a root bash login shell | yes | no |
| windows/cli | `Z:\` | an elevated PowerShell 5.1 | no | yes |
| windows/gui | `Z:\` | an elevated PowerShell 5.1 | yes | no |
The Windows fragment carries the two facts that have each cost a session already: **5.1 has no
`&&` or `||`**, and you are already inside PowerShell so wrapping in `powershell.exe` starts a
second one. The CLI fragment says plainly that there is no screen and nothing can click. A
missing fragment is skipped rather than raised on - a thinner prompt still runs a session, and
refusing to start one over a missing file would be the worse failure.
**Result.** A Windows CLI session, re-run: 4/4 tasks, every task naming `Z:\`, **`/mnt/project`
absent from the entire report**, supervisor verification passed.
---
**The live view, as the operator drew it.** Three boxes in GUI, two in CLI:
```
Live agent output Live agent output
Task List: Task List:
tasks tasks
----------------- -----------------
-----------------
the machine's screen
-----------------
----------------- -----------------
command feed (20) command feed (10)
----------------- -----------------
CLI GUI
```
**The live line meant making the agent's calls stream.** They were non-streaming on purpose -
the agent acts on a finished message, so nothing in the loop needed tokens as they arrived. True
of the agent, false of the person: measured during this work, the progress file sat unchanged
for over a minute while the GPU ran at 83% and the guest at 69% of a core. Everything was fine
and the screen said nothing, which is indistinguishable from wedged.
So `chat()` takes an `on_text` callback. Without one it streams nothing and behaves exactly as
before; with one, the reply is read as it arrives and the assembled message is **identical in
shape**, because the agent is not allowed to care which path produced it. The callback belongs to
the watcher, so it is called inside a suppressed block: a display that can fail a turn is worse
than no display. Thinking is shown only while there is no content yet - a model that is thinking
has not said anything, and a blank minute reads as hung.
**And the GUI feed would have been blank rows.** Every tool a GUI session uses is a desktop verb
- `screenshot`, `click`, `key`, `type`, `scroll`, `move` - and **none of them had a renderer**,
so the third box would have shown ten empty lines for the whole session. They render now.
**Result, from a real GUI session:** the live line moving continuously through the model's own
words, `shot_seq` advancing 0-3, and the feed holding exactly 10 lines - half the CLI box, as
asked.
### 59.36 — it could not put a file down and come back for the rest
**Symptom.** A session spent **twenty-two minutes and roughly fifty thousand tokens inside one
turn**, designing a WPF application in its head, still on task one of twelve, having made no tool
call for twenty minutes. It was not stuck and it was not looping - the live line advanced every
second and never repeated.
**Cause, and it is not the model's discipline.** The agent had `write_file` and nothing else.
Every change to a file meant reproducing the whole file, so a file it had to produce in one call
was a file it had to design completely before it could write any of it. The 2 MB limit was a
resource limit, not a forcing function; there was nothing to make it stop and nothing to come
back to.
The operator's framing was the right one: *think about how your own tooling is set up*. And the
answer is that **I almost never write a whole file - I edit one.** Exact-string replacement, one
small change at a time, failing loudly when the target is missing or ambiguous. The agent had no
equivalent.
**Change.**
* **`edit_file(path, old, new, replace_all)`** - exact text, and it **refuses** when the text is
absent or appears more than once. The refusal is the feature: text that is not there means a
mistaken belief about the file, and being told now is far cheaper than a silent half-edit found
three sections later.
* **`append_file(path, content)`** - build forward a section at a time.
* **`write_file` capped at 16 KB**, with a refusal that explains why and names the way past it.
This is the forcing function; the cap message keeps the escape hatch for generated data.
* **`num_predict: 16384`** - there was no output ceiling at all, which is how one turn ran
twenty-two minutes.
* The prompt now says: skeleton, parse-check, one section per turn, commit as you go, link it
together last - with the parse-check command for each machine.
**Three bugs fell out of testing it, each found by using the thing rather than reading it.**
**1. `mkdir -p "$(dirname ...)"` sent to PowerShell.** `write_file` ran that on every guest, and
the result was discarded. On Windows it failed every time, so any nested path failed to write; a
top-level file worked because `Z:\` exists. Rewritten in PowerShell it failed AGAIN, differently:
**5.1 rejects `Split-Path -Parent -LiteralPath` as an ambiguous parameter set**, so `$p` was null,
`Test-Path` and `New-Item` both bound null and did nothing - and because those are
*non-terminating* errors PowerShell still exited 0. Measured from inside the machine:
`PARENT=[]`, `EXISTS_AFTER=`, exit 0, no directory. **An exit-code check could not have caught
it.** vmd now splits the path itself - it built the path, so it knows the separator - and
*confirms* the directory exists rather than trusting a status code.
**2. The first release on a new repository always failed.** `422 repo is empty`, moments after a
push that plainly succeeded: Gitea clears `is_empty` asynchronously and the release API reads that
flag. It never showed on a repository that already had commits, which is why it survived two
sessions. Now retried with a short backoff.
**3. Answering the agent's own question was rejected.** The tool tells the model to call back with
`session_id` and `answer`; the schema required `task`, `os` and `interface` on every call, so the
reply came back *"task: Field required"*. They are optional in the schema now and required where a
new session actually needs them, with a refusal naming which is missing.
**Result.** The whole file-tool path driven on Windows, with `run` forbidden for the writes:
nested `write_file` with no directory pre-created, `append_file`, `edit_file`, the ambiguous-text
refusal verbatim, `read_file` - all correct, first attempt, and a release published on a
brand-new repository.
**Still open, found and not fixed:** there is **no resume path**. `session_id` is documented,
accepted, and used by nothing - `runner.py` never reads it, so an `answer` arrives as a fresh job
against a machine that is already running. `ask_operator` therefore strands a session. Recorded
rather than patched under time pressure.
### 59.37 — one kind of session, because the choice was never the caller's to make
**Symptom.** Asked to "build a gui powershell that acts as a windows troubleshooting toolkit",
the model chose `interface: cli` - a session with no screen, which can write a desktop
application and confirm it parses and can never say whether the window opened. Told not to, in
the tool docstring AND in the system prompt, it did it again on the very next run.
**Cause, eventually.** I built a refusal to stop it - `needs_a_screen` in `vm_preflight`, narrow,
tested, and it worked. Then the operator pointed out the obvious thing I had walked past: the
choice did not need to exist. I had written a mechanism to defend a decision rather than removing
the decision.
**Three separate costs, all measured, all from the same split:**
* **The caller chose wrong**, twice, with the rule in front of it both times.
* **A `gui` session had no cheap way to look anything up.** The lookup suite was CLI-only, on the
reasoning that a desktop session could read anything by opening it in the browser - true, and
far too expensive: `open_page` costs a screenshot per scroll where `read_page` costs one call.
So it reasoned instead. Eleven minutes and eight context compactions deriving how to run
background work in PowerShell, which the documentation states outright, while a parse-clean
19 KB application it had already written sat untouched. Another spent twelve minutes writing
nothing at all, the live line reading *"I recall precisely: WinForms processes docking in
reverse z-order"*.
* **A `cli` session could build a window and never see it**, so "it parses" was the best answer
available to it.
**Change.** There is one kind of session. Every one gets the command line, the lookup suite and
the screen; the agent decides what it needs, which it is far better placed to know than the
model that called it. `interface` is gone from the tool schema entirely - one less required
parameter, one less thing to get wrong - and `needs_a_screen` went with it.
The rest follows:
* **The desktop is waited for lazily**, on the first screen verb, so a job that only runs
commands never pays for GNOME finishing its paint. That was the whole argument for having a
cheap mode, and it survives without the split.
* **No screenshot is taken at handover.** It used to attach one automatically, which put a
picture of an idle desktop in the chat before the session had done anything, for every job
including the ones that never look at a screen. **The preview box now appears only when the
agent takes a screenshot**, so its arrival means something.
* **One terminal height** (20 lines). It was halved when a screen was shown; the screen is now
the exception rather than the rule, so the feed is not shrunk for something that may never
arrive.
* **One step budget**, the generous one (110), because any session may now look at a screen and
seeing the result of an action is its own turn.
* Two prompt fragments instead of four: `prompt.{ubuntu,windows}.md` plus one `prompt.screen.md`
that says the screen is there and that most work does not need it.
**The lookup suite in every session is the operator's suggestion**, and it is the one that
addresses the failure directly: a session that cannot cheaply look something up will reason about
it instead, and reasoning is what was eating the sessions.
**Result.** `tests/test_vm_four_sessions.py` became `test_vm_two_machines.py`. Thirteen suites
pass. The run under way when this was written: 6 of 10 tasks, a 32 KB application built with
`write_file` then `edit_file`, launching its GUI - the best of six attempts at this prompt.
### 59.38 — tuning it by running it, and the one parameter that was wrong
Two campaigns, driven through the real chat rather than the API, because the API returns the
final message and nothing else - the dialog, the live line, the boxes and their heights are all
invisible to it, and every defect found in this round was a visual one.
**Windows: "build a gui powershell that essentially acts as a windows troubleshooting toolkit".**
Eight attempts. The interesting ones:
| | outcome |
|---|---|
| before this round | 0/12 tasks, no tool call for 20 minutes, one turn of ~50,000 tokens |
| WinForms, old tooling | 7/10, app built and launched, all tabs screenshotted |
| WPF/XAML, old tooling | **1/12**, 48 minutes on the skeleton, 22 compactions |
| WinForms, tuned | **7/9**, 365-line 7-tab app, README, parse-checked, launched, *window confirmed rendering with all tabs* |
The middle row is the useful one: same code, same prompt, and it failed because the agent chose
XAML over WinForms. That is approach variance, not a tooling fault - and it is what made the next
change worth making.
**The agent's `presence_penalty` was 1.0, copied from the main assistant's row.** That is right
for the main model, which writes prose: a penalty on tokens already used discourages repetitive
phrasing. It is wrong for this agent, which writes **code**. A 25 KB script says `$form`,
`$output` and its own function names over and over - that repetition IS the program - and a
penalty on it is steady pressure toward a different token than the correct one. Two failing
sessions stalled on exactly that shape of bug: *"a scope issue with how I'm accessing the log"*,
and *"it's using `$a[0]` when `$a` was never actually defined"*. Qwen3's own guidance says a
presence penalty may degrade output and to use it only against repetition loops - and the agent
already has a repeat guard for those, in tool output, where it can say why.
Set to **0 for the agent only**. The main model's row is untouched: `temperature 0.6`,
`max_tokens 32768`, `presence_penalty 1`.
**And the task list had gone flat.** A session sat on "1/12" for twenty minutes while the file
grew and `edit_file` calls landed. The work was fine; the *report* of it was a flat line, and
somebody watching would read that as stuck. The prompt already asks the agent to mark progress
and asking has not worked, so it is now a nudge in tool output after twelve steps with no task
movement: *"Whoever asked for this is watching that list and it still says you are where you
started."* A nudge and not a refusal - being mid-task is legitimate, and blocking on it would be
wrong far more often than right. Measured on the next run: the agent went 0 to 5 of 8 in one
step after it fired, and the nudge is visible in the command feed.
**Ubuntu: "watches a folder and turns any new png into a compressed jpeg, keeping the original".**
**7 of 7, first attempt, status Done** - and it never took a single screenshot, which is the
one-session change working exactly as intended: the agent decided it did not need the screen, so
the preview pane never opened. The evidence file it left behind is the point of the whole
subsystem:
```
PNG sha256 (before): 77dac1f6...97ba
PNG sha256 (after): 77dac1f6...97ba
demo/photo.png: 1157239 bytes demo/photo.jpg: 242441 bytes
JPEG smaller than PNG : YES PNG unchanged : YES
```
**What is still hard, stated plainly.** Background/async work in PowerShell defeated the agent in
three separate sessions - runspaces, threads, `Start-Job` - and it is the only thing that did.
The scope rule tells it to take the duller approach after two failures and it does not always
listen, because that is a prompt rule. Making it a tool-output signal is the obvious next move
and is not done.
### 59.39 — the sweep, and the bug it found in the last place anyone looks
A final pass over everything, driven through the chat. It found one real defect, and it was in
the step that ends a session.
**`finish` was refused with an internal error, every time.** A session wrote the script,
syntax-checked it, built a fourteen-file test tree with awkward filenames, cross-checked its
output against an independent listing, demonstrated the error paths, committed, and passed its own
verification command - and then could not close:
```
File "/usr/local/lib/owuivm/vmd.py", line 1233, in v_finish
AttributeError: 'str' object has no attribute 'get'
```
`finish` declares `verification` as `{command, expected}`; the model sends the command as a bare
string often enough to matter. The agent saw only "the supervisor refuses the call", tried seven
times with varying arguments, marked the task blocked and gave up. **All the work was done and
committed. The session simply could not be ended.**
The identical mistake had already been found and fixed in the live view's renderer (59.36), where
it cost a line of output. Nobody checked the verb that reads the same argument. The rule is the
same wherever a model-supplied value is read: **take what arrives, not what was asked for.**
`tests/test_vm_finish_shapes.py` covers both shapes and four kinds of junk.
Re-run after the fix, same prompt: **8 of 8 tasks, status Done, four minutes.**
**Also seen, and fixed in the prompt rather than mechanically:** the agent built its test tree
*inside* the project folder, so two 256 MB fixtures were committed and published. They were sparse
and compressed to nothing - `.git` came to 566 KB - and the release asset cap skipped them, so
nothing broke. On real data it would be a quarter-gigabyte blob in somebody's repository. The
prompt now says scratch belongs in `/tmp` and the project folder is for what was asked for plus
the evidence worth keeping.
**Final state of the sweep:** fourteen suites pass; `owuivm` inactive, no qemu, no overlays, no
pending jobs; both base images `0444`; every container healthy. The live view was re-checked in
the browser on a fresh session - two boxes before any screenshot, the live line above the task
list, feed at full height, nothing stuck at the 150px default, no horizontal scroll - and the
confirmation dialog fits its window with the buttons reachable.
---
**Also measured:** boot time fell from 2min 14s to 20.9s once `systemd-networkd-wait-online`
was masked (`graphical.target` at 9.5s rather than 2min 3s); a session opens and answers on its
guest agent in ~27s; a click at an absolute coordinate landed on the button it was aimed at and
advanced a GNOME dialog; two sessions on the same project name saw the first session's files.
**Not measured:** the cost of an Ollama handover. The design assumes a cold prefill of the main
model's context on every return, which is why one long blocking call is preferred to polling,
but no benchmark has been run on this V100 — the figure used to reason about it is an estimate.
The Windows guest now exists and has been measured
(59.26, 59.28): a session opens on it, and it resumes from a saved machine rather than booting.
The live-progress channel *has* now been watched end to end from a browser - the filter is installed,
and the two boxes were measured at 86px and 364px during a running session (59.30). GUI mode has
been proven verb by verb - screenshot, absolute click, key, type - but never driven by the model.
### 59.40 — the release carried the debris and not the work, and the prompts said things that were no longer true
**Symptom, in the operator's words.** *"When a release is built in gitea it only has the files in it
relevant to the actual release and not include any ambiguous testing tools / scripts"* — and
*"clean up the prompting where needed to avoid confusion."*
**Measured.** Release `v3` of `windows-troubleshooting-toolkit` held ten files — `_build.ps1`,
`_launch.bat`, `_structcheck.ps1`, four `_w_*.ps1` fragments, `check_disp.ps1`, `check_disp2.ps1`,
`check_disp3.ps1` — every one of them scratch, and **not** the 34 KB `TroubleshootingToolkit.ps1`
the session was asked for. Two causes compounding: `git add -A` committed whatever the agent had
left in the folder (30 files, 25 of them probes, captured output and a compiled `test_cs.exe`), and
`tracked_files()` sorted `_build` ahead of `TroubleshootingToolkit` and stopped at ten. Three other
releases shipped `test_*.ps1` beside the deliverable; `top-ten-files` had 1.5 GB of `testtree/`
fixtures committed.
**What actually downloads, measured again.** Uploaded release assets come back
`Content-Disposition: inline` — a `.zip` asset included, `application/octet-stream; inline`. The
only attachment on a release page is Gitea's own `/archive/<tag>.zip`, built from the tree the tag
points at. So a curated asset list would have left the one link that downloads full of junk: **a
release is only as clean as the commit behind it**, and the filter had to go at the commit.
**Change — the commit.** `gitea.py` gains `SCRATCH_PATTERNS`, applied as `:(exclude,glob)`
pathspecs to `git add -A`: a leading underscore, `test_*`/`test-*`/`*_test.*`, `smoke.*`, `probe_*`,
`tmp_*`, `*_out.txt`, `*.out`, build products (`*.exe`, `*.dll`, `*.obj`, `*.o`, `*.pdb`, `*.class`),
and whole trees under `test*/`, `tmp/`, `scratch/`. Deliberately narrow: `check_*` and `find_*` are
*not* excluded, because `find_duplicates.py` was a real deliverable and a "check disk health" job
would reasonably produce `check_disk.py`. Three guards on the filter:
1. A project that would otherwise publish *nothing* — everything reads as scratch and nothing
publishable was ever committed — publishes everything, because then the tests were the job.
The freshly written `.gitignore` is discounted when deciding that, or it alone made an
all-tests folder look like it had something publishable in it.
2. Scratch an *earlier* session already committed is untracked (`git rm --cached`) on the next
commit, so a resumed project stops publishing its old junk without losing the file on the array.
3. `git_commit` names what it held back, in tool output, with "if this *is* the deliverable, give it
a name that says so". A filter nobody mentions is indistinguishable from a bug.
`has_changes()` and `commit()` share one `_staging_plan()`, or the end-of-session backstop decides
there is something to commit and then stages nothing, which git calls an error. The asset cap goes
from 10 to 25: it exists to stop a file dump, and the dump no longer reaches the commit.
**Change — the stranded session.** `ps-troubleshoot-toolkit` had a 20 KB toolkit and five test
scripts on the array, zero commits, no repository, no link. Its machine died before the loop could
call `session_close` (the agent container was rebuilt underneath it), and the only path that then
saw the record — `session_open`'s stale-record cleanup — destroyed it without publishing. The reply
the user got carried a confident link to **a different project's** release, assembled from the task
wording. Now the stale-record path runs `_publish` before it discards the record; the report states
"There is no link to give for this session" as a fact when nothing was published; and the main
prompt and the tool docstring both say to hand over only the link in the result and never build one
from the project name.
**Change — the prompts.** `prompt.md` promised *"in a desktop session you will be handed a picture
of it"* — the handover screenshot had been removed a change earlier. "In either mode" survived the
removal of the modes. The lookup guidance was written twice, `read_page`/`open_page` explained
twice, "GUI included" in one of them. "Nothing graphical you start from `run` can be seen" was said
in four files. `/tmp` sat in the machine-neutral prompt — the same shape of mistake as the Windows
session that planned to "check /mnt/project accessibility". All of that is now said once, in the
file it belongs to; the scratch location lives in each machine's own section; the commit filter is
described where the project folder is. Stale CLI/GUI comments in `tools.py`, `vmd.py`, `websearch.py`
and `compact.py`, and the filter's valve descriptions ("In CLI sessions…"), were rewritten.
**Tests.** `tests/test_vm_release_contents.py` rebuilds the failed release from its real file list
and asserts the toolkit is committed and the scratch is not; that `find_duplicates.py`,
`Check-AutoStartServices.ps1` and `latest/` survive; that an all-tests project still publishes; and
that a resumed project un-publishes legacy scratch while keeping the file. `tests/test_vm_prompt_consistency.py`
reads the text the two models actually see — prompts, tool *descriptions* (not comments), the tool
docstring, the report — and fails on mode language, the handover-picture promise, a machine path in
the neutral prompt, a tool name the agent does not have, and the same sentence said in four files.
17 suites pass.
**Dry run against the array, read-only.** Every real project: `ps-troubleshoot-toolkit` would
publish `Toolkit.ps1` and hold back its five `test_*.ps1`; `top-ten-files` would un-publish 1.5 GB
of `testtree/`; `windows-troubleshooting-toolkit` would un-publish all 25 scratch files and keep the
toolkit. One judgement call flagged to the operator: `systemd-restart-test`'s `units/test-*.service`
are fixtures by name and arguably the artifact; they would stop being published, leaving `REPORT.md`.
**Also found.** Open WebUI 0.11.3 with no Redis configured refetches every tool server's OpenAPI
spec on each chat request (`get_tool_servers` → `set_tool_servers`), so a changed tool docstring is
live as soon as the api container is up — no re-save, no restart. And the `vmagent` bot token has
write but not delete rights: the probe repository `vmlab/zip-asset-probe` used to measure the
download headers could not be removed by the API and needs deleting from the web UI.
### 59.41 — a live line you can read, and three boxes you can fold away
**Asked for.** Smooth the live token stream above the task list so it arrives token by token
and its speed shows how fast generation actually is. Make each box fold when clicked: the task
list down to two lines (the live line and the current step), the screen preview to half size
with black either side, the command feed to half its height. Click again to restore. No tooltip.
**The problem underneath all of it.** The view is re-sent every couple of seconds, and each send
replaces the frame's `srcdoc` — a new Document every time. A click has to outlive that, and the
frame is sandboxed without `allow-same-origin`, so it has an **opaque origin**: `localStorage`
and cookies throw, IndexedDB is useless, and Open WebUI has no listener to `postMessage` to.
There is no server channel either — the filter polls, nothing calls back.
**Measured, in that exact sandbox: `window.name` survives.** It belongs to the browsing *context*,
not the document, and the context is what the swap keeps. A frame reloaded three times read back
the name its two predecessors had written. That is the whole mechanism: each box keeps
`{k, c, d}` there — which box it is, whether it is folded, and the text the ticker has shown so
far. It is keyed by box because the screen box appears mid-session and pushes the feed down a
slot, so the frame that was the feed becomes the screen and would otherwise inherit its state.
**The ticker.** The server sends the *tail* of what the model is saying, at most once a second,
so the line used to jump a clause at a time — motion that said nothing about throughput. The
frame now computes the overlap between what it has already shown and the new tail, and appends
only the remainder, a few characters per 40 ms frame, draining over ~1.5 s. More characters in
the window means faster typing, so the speed on screen *is* the token rate. It is right-anchored
(`justify-content:flex-end`), because the end of the sentence is the part that is moving.
**`flex:none` on the feed rows is load-bearing.** The rows sit in a flex column so a folded feed
keeps its newest lines against the bottom and loses the oldest off the top. Flex items shrink by
default, so the first version compressed twenty rows into ten rows of space and the text
overlapped itself — caught by eye in the harness, not by any test.
**What the browser could not tell me, and how that was worked around.** Dispatched clicks from
the automation tool **never reach iframe content** — proven with a page holding one sandboxed and
one plain frame, each with a click handler: the parent's own button fired, neither frame did. So
interaction was verified by replaying the real generated frames into non-sandboxed iframes and
dispatching clicks through the DOM, while `window.name` persistence was measured separately in a
real sandboxed frame. Both halves measured, in a browser, before the code was believed.
**Result**, in a real frame: task box 196px → 51px and back; screen 500px → 269px with the image
at exactly half its width (740 → 370) on black; feed 364px → 190px showing ten rows at an
unchanged 17.4px. All three survived two srcdoc swaps in the folded state.
`tests/test_vm_live_view_collapse.py` locks the contract — the per-box config block, the three
ids each box needs, the `window.name` keying, the toggle re-reporting its height, the overlap
arithmetic, and `flex:none` on every row. 18 suites pass.
**Note for whoever changes this next:** a running watcher holds the module it was started with,
so a filter deployed mid-session does not reach the session already being watched. The new view
appears on the next turn, not on the one in flight.
### 59.42 — the transcript was always there, and was thrown away every time
**Symptom.** Asked "did the agent ever open the app and look at it", I could not answer from the
record, and answered about the wrong run while doing it. The operator's reply: *"You're definitely
not looking at the logs correctly ... I want you to set up proper logging so that its easy to go
back and see exactly what the LLM did without getting confused."*
**Cause, and it is not the one it looks like.** Nothing was missing from the *recording*.
`_record()` has covered `screenshot`, `click`, `key` and `type` since they were written, and
`STATE.transcript` held the lot. It was discarded when the session ended, so the only durable
records were the two that cannot answer the question:
* **the tool result counted `run` and nothing else.** A session that marked "clicked through the
GUI" done without ever calling `click` read exactly like one that did.
* **Open WebUI's status history is SAMPLED.** The filter polls every two seconds and reads
whatever the status says at that instant, so a verb that returns fast is usually overwritten
before it is ever seen. Measured on a real session: 456 samples caught `launch_app` nine times,
because it blocks for seconds, and caught **none** of the screenshots that demonstrably
happened. Reasoning from that record is reasoning from a record that cannot be complete.
**Change.** `vm/vmd/sessionlog.py` writes the transcript to `/var/lib/owuivm/sessions/` as it
happens, one JSONL file per session, flushed on every line - the session someone wants the log for
is precisely the one that got killed. One hook in `_record()` covers every verb, including any
added later. It carries the **chat id**, so a conversation maps to exactly one session and nobody
has to guess from timestamps again. A harness-only `note` verb records what the model *said* each
turn, so the file reads as an account rather than a list of calls; the model is never told it
exists. `vmctl logs` lists them and `vmctl log [chat|session|file|project]` renders one, neither
needing a daemon or a machine. The last four verbs that were doing their work unrecorded -
`read_file`, `list_dir`, `scroll`, `wait`, `check_internet` - now record too.
The **tool result** carries the same counts, so the reader sees them without opening anything, and
when the screen was never looked at it says so: *"Any claim about how something appears is
inference, not observation."*
**The bug this found immediately, which is the interesting part.** The first real session wrote no
log at all, silently. vmd runs under `ProtectSystem=strict` and `/var/lib/owuivm/sessions` was not
in `ReadWritePaths`, so every write was denied - and the log is built never to break a session, so
it swallowed all of it and looked exactly like a session with nothing to record. **A feature that
silently does nothing is worse than one that fails loudly.** The directory is now in the unit, and
a log that cannot be opened prints one line to the journal saying why while still returning
quietly.
**Result**, a real hand-driven session read back with `sudo vmctl log`:
```
0:58.5 ready booted in 57.7s
1:11.7 plan 2 tasks
1:13.0 run exit 0 1.06s uname -a; echo hello from the log check
1:17.4 screenshot 1280x800 (390 KB)
1:17.6 · turn 1: Checking that the session log records what actually happened.
what it actually did: 2x task, 1x check_internet, 1x plan, 1x ready, 1x run, 1x screenshot
it never called click
```
`tests/test_vm_session_log.py` walks the AST and fails if any verb touching the machine records
nothing, following the one level of delegation that `v_click` and `v_move` use. 19 suites pass.
### 59.43 — the run was fine; the window onto it closed four seconds in
**Symptom, in the operator's words:** *"the tool just gave up it seems like."*
**What had actually happened.** A conversation asked for a GUI app, got one, then asked for a fix
to the layout. The second `vm_agent` call ran for **45 minutes, closed all eight tasks and
finished successfully** - and the chat showed an empty message under a toast reading "The machine
has been destroyed". The whole status history of that message was seven lines:
```
Calling vm_agent / VM · Starting a machine… / VM · Done / The machine has been destroyed.
```
**Cause.** The live-progress file is named after the **chat**, not the run:
`/vmjobs/progress/<chat_id>.json`. The first run left it with `done: true`. The second run's
watcher started, polled once, read the *previous* run's ending, announced it and exited. The run
then wrote to that same file for three quarters of an hour, reaching sequence 1035, with nobody
reading it. Every second `vm_agent` call in a conversation had this, and the first run in a fresh
chat never did - which is why it survived so long.
**Change, in two places, because a race with one guard is a race.** The api deletes the previous
run's progress file (and any stale confirmation answer, which would otherwise decide a question
nobody was asked) **before** the job is queued, which is authoritative. The watcher additionally
ignores any progress stamped earlier than the moment it began - `updated` is written on every
flush, so a file from the last run is simply older than the watcher reading it.
**The other thing that session did, found in the new log.** It spent its last dozen turns fighting
`git` inside the machine: `exit 128`, "dubious ownership", `safe.directory`, `HOME`. It even wrote
*"Let me use the git_commit tool"* at turn 99 and then went back to raw git. The guest sees `.git`
owned by another uid, so this can only ever end in that wall - and a commit that did succeed there
would skip everything `gitea.py` does on the host: hooks and filters pinned off on the command
line, the right author, and scratch left out of what gets published.
So `run` now refuses a mutating git subcommand and says why, naming `git_commit`. `git` has to be
in **command position** for the refusal to fire, or `echo git commit > notes.txt` reads as a
commit - a refusal that fires on a string inside an echo is worse than no refusal. It is a
signpost, not a sandbox: a guest that insists can still write to `.git`, and what protects the
published history is the host-side hardening, not this.
**Verified** against the real command lines from the failed session - `git add -A`,
`git config --global --add safe.directory`, `git -c safe.directory=... add`, `git -C ... commit`,
`/usr/bin/git push` - all refused; `git log`, `git status`, a quoted string in `python3 -c`, a
`grep` pattern and an `echo` into a file all untouched. The stale-file case was replayed in the
api container: a planted `done: true` from ten minutes ago is deleted by the submit path and
ignored by the watcher's stamp. `tests/test_vm_second_run.py`; 20 suites pass.
**The general lesson, which has now cost two debugging sessions:** state keyed by conversation
outlives the thing it describes. The session log (59.42) is keyed by session and carries the chat
id, which is the right way round - and it is what made this diagnosable at all, since it showed
the run working normally throughout the window in which the UI said it had died.
---
## Change 60 — one place to configure it, and a switch that actually removes a tool
**Symptom.** Nothing was broken; it was unpublishable. Configuration lived on four surfaces
and only one of them was in the repo: `.env`; `/etc/owuivm/vmd.env`, written once by
`install.sh` and never looked at again; bind-mounted service config scattered across six
top-level directories; and state that existed only inside Open WebUI — the system prompt, the
filter valves, and the Function Name Filter List, which was the *only* way to turn a tool off.
"What does this deployment offer, and where do I change it" had no answer on disk.
The tree also carried one house's identity in ~60 places, including the operator's username as
a **functional gate** in the polkit rule, and one live secret committed in the clear
(`searxng/settings.yml`).
**Change.** Everything editable moved to `configs/`: the stack env, a tool switch, the machine
registry, the assistant's system prompt, and the sub-agent's prompts, parameters and tool
definitions. `docs/` gained installation, configuration, per-tool setup pages, the recommended
parameters, and sanitised compose files for the three services that live outside the repo
(Ollama, Open WebUI, Gitea). The testing corpus moved under `docs/testing/`.
Four things are worth recording beyond the reorganisation.
**`configs/tools.toml` removes a tool rather than hiding it.** A tool set to `false` is dropped
before `include_router`, so it is absent from the OpenAPI spec *and* its endpoint 404s. The 22
schemas cost ~9,700 tokens on every turn of every conversation, so hiding one saves nothing;
removing it is the only version of the feature worth having. Missing or unparseable config
leaves everything on, deliberately: this is config that removes capability, and its failure
mode should be the system it replaced.
**The seam is the routers, not `app.routes`, and the first version of this shipped that bug.**
FastAPI 0.141 stopped flattening included routers — `include_router()` appends one opaque
`_IncludedRouter`, so `app.routes` contains no `APIRoute` and no `operation_id` at all.
Filtering it removed nothing while looking exactly as though it had worked: 22 tools in, 22
tools out, and a log line claiming 22 unknown names. Caught only because the probe asserted
*which* tools disappeared rather than that the call succeeded.
**The sub-agent's 34 tool schemas moved from Python to `configs/agent/tools.toml`** — ~380
lines of tuned prompt text, where a dropped sentence is not a crash but a session that goes
wrong later for no visible reason. The move was made safe by capturing the hand-written output
to `tests/golden/agent_tool_schemas.json` **before** touching anything, then asserting the
config-driven builder reproduces it byte-for-byte for both machines and for the booting case.
It does. The per-machine variation (`{shell}`, `{project}`, `{example}`) is substituted over
the serialised form, because the placeholders appear in parameter descriptions as well as tool
descriptions. Dispatch stayed in code behind a `DISPATCHABLE` allowlist, so a config file
cannot operator the agent a verb — a name with nothing behind it is refused at read time, naming
the file, rather than offered to the model and failing somewhere less obvious.
**Two QOL changes to the maintenance app, and the privileged half of one is the interesting
part.** *Insert disc…* replaces the dead end where a machine with no media said "no ubuntu ISO
in /var/lib/owuivm/iso" with a greyed-out button. The obvious implementation — pass the chosen
path to a privileged unit and let it copy — does not work and should not: the maintenance units
run `ProtectHome=yes` so the unit cannot see `~/Downloads`, `owuivmadm` is not in the operator's
group so it could not read a 0750 home anyway, and running the copy as root would hand the
operator a "copy any file root can read into a world-readable directory" primitive in exchange
for saving one `mv`. So the app copies as the operator into a staging directory the operator
owns, and the unit checks it and moves it. The check includes the ISO 9660 signature at offset
0x8001, which catches the case this will actually meet: a download that was really an HTML
error page, which would otherwise fail much later as a machine that will not boot.
*Rename* changes the label and never the id, which names the disk, the unit instance, the
saved-machine directory, the `os` the assistant passes, and appears in regex allowlists inside
the privileged launch scripts. It writes a small override file rather than editing
`configs/vms.toml`, because that file is mostly comments and no TOML writer preserves them — a
program that edits the registry in place is one that deletes its own documentation the first
time someone presses Rename.
**Result.** 22 suites pass (20 existing, unchanged, plus the toggle and golden-schema tests);
`tests/smoke.sh` at 174/175 before a stale case was fixed and 175/175 after; the host installer
re-run clean with the Gitea token preserved; all ten containers healthy.
**Two things this turned up that were not part of the work.**
*A smoke case had been silently starting a real VM session.* It passed `interface:
"telepathy"` with a valid project and expected a refusal — true when `interface` was validated,
but the cli/gui split was removed in 59.x and `vm.py` now hardcodes it. The nonsense value is
simply ignored, so the request became a perfectly good one, started a machine, and died on the
90-second per-case timeout with a transport error. The suite's own comment says only refusal
paths belong in it. It now pairs the bad interface with a project name that is refused outright,
so it asserts both things and cannot start anything. The session itself was cancelled correctly
by the disconnect path and left nothing behind, which is why this had gone unnoticed.
*Recreating a network-namespace owner strands its dependents.* `docker compose up -d netguard`
left `api` and `searxng` running, healthy by their own healthchecks — which are loopback calls —
and with no route to anything: squid and valkey both "Network is unreachable", the gateway
getting connection refused, and every container reporting Up. Docker does not re-attach a
`network_mode: container:` member when its host is replaced. Written into `docker-compose.yml`
beside both guards, because the failure reports as healthy.
**Measured:** the golden schemas are byte-identical across both machines; a disabled tool is
absent from `/openapi.json` and 404s; a missing config leaves all 22 on; the insert helper
rejects a non-ISO, a symlink, a path-shaped id and an unknown machine; the rename unit writes
only the override file. **Not measured:** the maintenance app's two new buttons under a real
polkit prompt — the units were driven directly, and the window was confirmed to build and run,
but the operator has not yet clicked through an install from a freshly inserted disc.
---
## Change 61 — portable enough to hand to someone else, and one place to read the logs
**Symptom.** Change 60 made the repository publishable. It did not make it *usable*: a
stranger who cloned it could not have got it running, and several of the things that would
have stopped them were invisible from inside this deployment, where they happen to be true.
**Cause, in four parts.**
*The pool.* `/Pool` was written into eleven functional files — three vmd modules, five host
scripts, three systemd units. `install.sh` had a `$POOL_ROOT` variable by then, and it
reached almost none of them.
*The machine's shape.* `AllowedCPUs=20-27`, `MemoryMax=11G`, `CPUQuota=700%` and a Gitea
allowlist naming one LAN address, all baked into units. Meaningless or actively wrong
anywhere else; `AllowedCPUs` on a four-core machine would refuse to start.
*Nothing checked.* `install.sh` created users, datasets, units and firewall rules and then
found out at step nine whether `swtpm` existed, leaving a half-installed host.
*And the documentation was confidently wrong in two places.* Both only discoverable by
doing what it said on a machine that was not this one.
**Change.** Every host artefact became a template with `@PLACEHOLDER@` tokens, rendered by
one `render()` in `install.sh` from values derived from the hardware — `nproc`, `MemTotal`
— and overridable in `configs/host.env`. `render()` **refuses to install a file that still
contains a placeholder**, so a token added to a template and not to the installer fails at
install time rather than as a unit that will not start. The pool reaches vmd through
`vmd.env`, which the daemon already loads.
A preflight now checks every binary, the firmware files, `/dev/kvm` and the pool *before
anything is created*, and creates the two datasets if they are missing. `tests/test_vm_host_render.sh`
renders the whole set with deliberately foreign values (`/tank`, `6G`, `300%`, `someone`)
and asserts no line that systemd or bash acts on still carries ours — comments excluded,
because the measurements in them are the reason anyone could change a number safely.
Also made settings rather than facts: the Open WebUI network name, and the URL in the
outbound `User-Agent` — which every other deployment would otherwise have been announcing
as its own to every site it fetched from.
**Logs.** `./logs` is bind-mounted into all ten containers and each writes its own file:
thirteen of them, stdout unchanged so `docker compose logs` still works. Two needed more
than a config line. squid and valkey create their logs `0640`/`0600` as their own uid, so
the operator could not read them — a log nobody can open is not an audit trail; squid takes
`umask 022`, valkey needed `sh -c 'umask 022; exec …'`, with `exec` so it stays PID 1 and
shuts down cleanly. SearXNG can do neither: it logs through Python to stdout and the image
dropped uwsgi, so its entrypoint is teed. That makes PID 1 a shell that will not forward
SIGTERM through a pipeline, so stopping it waits out the timeout — acceptable there and
nowhere else in the file, because it holds no state. `owuivmd` stays on the journal: it is
a systemd service, and pointing it at this directory would tie a host daemon to wherever
the repository sits.
**Result.** The published copy was set up in a temp directory following only
`docs/install-ubuntu-desktop.md`: ten containers healthy, all thirteen logs written and
readable, **175/175 smoke**, 22 unit suites, and the documented toggle workflow verified by
switching two tools off and watching them leave the spec and 404. The live deployment was
stopped for it and came back identical — and `install.sh`'s output diffed byte-for-byte
against the nine files it had installed before the refactor, the only difference being
three lines of comment.
**Four things this found that inspection had not.**
*The release shipped this machine's logs.* `logs/*.log` was added to the exclude list and
they stayed anyway: `rsync --delete` treats excluded files at the destination as protected,
so a file published once stays published after it is excluded. Now removed explicitly.
*A fresh clone could not start at all.* `.env` is a symlink to `configs/owuitools.env` and
was excluded as though it were the secret. It holds nothing, and it is the only thing
`docker compose` reads for substitution — so every documented command failed on the first
required variable. The link ships; `make-release.py` asserts it is a symlink and that it
dangles.
*`curl http://localhost:8000/health` was in seven documents and works nowhere.* Nothing
publishes a port. Worse, it can appear to succeed: on this host something unrelated answers
on 8000 with `OK`, which reads exactly like a healthy tool server. Replaced with
`tests/try-tool.sh`, which calls from inside the container.
*Driving a session by hand did not work, and the documentation said it did.* `ready` is set
by the `session_ready` verb and nothing else, and every verb touching the machine is refused
until it is — but `vmctl` never exposed it. `vmctl open` then `vmctl run` returned "the
machine is still booting" for as long as you cared to wait, with the guest up and the agent
answering `guest-ping` the whole time. `vmctl ready` now exists.
**Measured:** a clean clone reaches 175/175 with no edits beyond the two the documentation
asks for; the host templates render with no trace of this machine in any acted-on line and
`systemd-analyze` accepts the results; all thirteen logs are written and operator-readable.
**Not measured:** `install.sh` on a host that is not this one. Its preflight and its
derivations are exercised, but the only end-to-end proof available here is that re-running
it reproduces what was already installed.
---
## Change 62 — a last read of the thing being published
**Symptom.** Nothing was failing. But everything verified so far had been verified *on the
machine that wrote it*, and the one full install test had been done before several later
edits. A pre-push sweep found four things, three of which only matter to someone who does
not already have this working.
**What it found.**
*Following the install guide in order would have failed at step 3.* Compose derives a
project name from the directory the compose file sits in, so
`docker compose -f docs/deployment/open-webui.compose.yml up -d` creates a network called
`deployment_default`. The tool server joins Open WebUI's network **by name**, expecting
`openwebui_default` — so the next step dies with "network declared as external, but could
not be found". Invisible here, because a real `openwebui_default` already existed. All three
deployment files now pin `name:`.
*`vmctl gitea-check` does not exist.* It was written into `docs/setup/gitea.md` as the way
to verify the Gitea setup and was never a verb. Replaced with a check that runs as
`owuivmd` and asks Gitea for the organisation — which proves the token, the file permissions
*and* the daemon's address filter in one go, and was run here to confirm it works.
*The compose header said `WHY SIX CONTAINERS`* over a file with ten, and claimed no host
bind mounts beyond read-only config — untrue since `./logs` and the agent socket directory.
Both rewritten.
*Two comments pointed at the pre-`configs/` layout*, naming `gateway/nginx.conf` and
`egress/squid.conf`.
**How it was checked.** Mechanically where possible, because reading does not scale: every
tracked file compiled or parsed by shebang; every repo path named in a doc resolved (115 of
them, the only absences being files the user creates); every tool name in the docs
cross-checked against `configs/tools.toml`; every `vmctl` verb against `vmctl`'s own
parsers; every `OWUI*_` variable against the code. That last set is what caught the
fabricated command — a verb that reads plausibly is invisible to a human re-reading their
own prose.
**Result.** 175/175 smoke, 22 unit suites, the isolation and render suites, and the
published tree re-validated from a clean copy: ten services resolve, every bind-mount source
exists, all three deployment files parse, and its own tests pass. Two `example.com` strings
remain in the repository and both are the image-source URL, kept deliberately.
**Measured:** the published tree stands up from a clean copy with no edits beyond the two
the documentation asks for. **Not measured:** the install guide end to end on a machine with
no Ollama, no Open WebUI and no `openwebui_default` network — the step-3 failure above was
reasoned from Compose's naming rule and fixed, not reproduced.
---
## Change 63 — one command that says what is set up, what is off, and what is broken
**Symptom.** There was no way to ask "is this working" and get an answer. `smoke.sh` runs 175
cases and prints 175 lines; it tells you whether the suite passes, not whether *your*
installation is ready. Nothing reported the difference between a tool that is broken, a tool
that is switched off, and a tool that is switched ON and not set up — and the third is the
only one that actually costs anything, because the model is being offered something that
will fail.
**Change.** `./verify.py`. It reads the config, asks Docker what is running, and calls each
tool once with a known-good argument lifted from the smoke suite. Every tool gets one line;
only the actionable ones get a second. Two axes, reported together:
All Set · All Set, Disabled · Needs Setup, Disabled · Needs Setup · Error · Error, Disabled
`logs/verify.log` holds what was checked and what each probe returned;
`logs/verify-error-<tool>.log` holds one failing tool's story on its own, because when one
thing is broken and twenty are fine you want the one. Stale error files are deleted when the
tool passes, so one cannot be found later and believed. Exit 0 / 1 (something enabled is
failing) / 2 (something needs attention but is switched off).
**Five things it got wrong first, all found by running it against a deliberately broken
system rather than by reading it.**
*A disabled tool was reported `All Set, Disabled` without checking anything* — it cannot be
probed, because switching it off removes the route. Now its dependencies are checked
structurally, which is what makes that label a claim rather than a shrug.
*`docker compose ps` hides stopped containers.* With octave stopped the stack still read
"All Set, 10 running", because a container that exited simply vanishes from the output.
Needs `-a`, plus a comparison against `docker compose config --services` to catch a service
with no container at all.
*Then that fix was too blunt:* one stopped container made `up` false and all 22 tools
"Not Checked". Probing needs the **api** container and nothing else; each tool's own
dependency belongs on its own row.
*A Python traceback's first line is `Traceback (most recent call last):`*, which is what the
terminal showed. The last line is the exception.
*And it waited 80 seconds to report `TimeoutError` for a tool whose container it already knew
was down.* Checking the dependency before probing turned that into "needs octave, which is
exited" in 3.7 seconds — the same finding with the cause in it.
Plus one bad probe of its own: `lookup_ip_or_asn` was given `203.0.113.10`, which the smoke
suite uses precisely because the tool REFUSES documentation ranges. It reported the tool
broken until the payload became a real address.
**Result.** On a working installation: 22 tools, 22 All Set, exit 0, about 40 seconds
(`--quick` skips the internet and takes four). On an unconfigured clone: three Needs Setup
rows naming the missing token and pointing at the install guide, exit 2 — and that exit code
was wrong until stack rows were counted alongside tool rows, which is the one case where a
non-zero code is most obviously wanted.
**Measured:** every status in the table above was produced deliberately - by switching tools
off, unsetting `OWUITOOLS_CONTACT`, and stopping a container - and each rendered correctly
with the right log files written and the right exit code. **Not measured:** `vm_agent` is
checked structurally and always will be; calling it starts a real machine.
---
## Change 64 — the published README told the reader to run a file that is not published
**Symptom.** The repository was pushed to Gitea and the rendered README opened with a
section called *"Publishing it"*, instructing the reader to run `make-release.py` from
`~/Documents/owui-tools`. That script is on the exclude list precisely because it carries the
sweep patterns, and the path is one machine's home directory. So the first file anybody reads
ended with an instruction that cannot be followed.
**Cause.** The section was written for the live tree, where it is correct and useful. Nothing
distinguished "documentation of the project" from "documentation of maintaining my copy of
it", and the release sweep only rewrites strings - it had no notion that a whole section
might not belong.
**Change.** `make-release.py` now strips a named list of sections from `README.md` on the way
out, currently just that one. The live tree keeps it.
**Worth noting about how it was found.** Not by the link checker, which passed: every link in
that section was to a heading or a shell command, not to a missing file. Not by the release
verifier either, which checks for things that must not be there rather than references to
things that are not. It was found by reading the README as rendered on the repository page -
which is the one view nobody had looked at, because until there was a repository there was
nothing to look at.
**Measured:** the published README now has eight sections and no mention of `make-release.py`
or of any path under `/home`. **Not measured:** whether anything else in the docs is
similarly addressed to the maintainer rather than the reader; the strip list has one entry
because one is all that has been found.