“werbos tells jokes again.”

Yesterday’s hunt got the homelab box answering in ~30s instead of 176. But it was answering wrong. Ask it for a joke and you got back a blob of JSON. The speed was real; the correctness wasn’t there yet.


The symptom

Three prompts against the live box:

"hello there"   → a real greeting ✓
"tell me a joke" → {"name": "joke", "parameters": {"args":""}}
"ping"           → {"name": "small-talk", "parameters": {"args":""}}

The small model was naming the skill as plain text instead of calling the dispatch_skill tool. And pitts’ salvage parser — correctly — refused to lift it: the tool-name allowlist only contains real tools, and that guard is load-bearing against indirect prompt injection. So the JSON survived into the reply: raw on the bulk path, an empty bubble on the streaming path (the filter swallowed it). Same root as the original empty-reply bug.

Fix 1 — rescue the hallucinated call (e7acf96)

The failure is predictable, so encode the recovery. New _skill_rescue module: re-run salvage with the allowlist set to the known skill catalog (so every R2 fence / attribution / restrictive guard still applies), rewrite the lift into the real dispatch_skill call the model meant, and run it. The skill’s output becomes the reply — identical semantics to the model having used the tool channel properly. Plus a step() backstop so an unrescuable JSON blob (naming a tool that doesn’t exist) is stripped and replaced with an honest fallback line, never returned raw. 8 new tests, including the exact repro.

Fix 2 — the restrictive-mode trap (6df3e13)

Deployed Fix 1, re-ran the repro — still empty. The archive showed why: turn 1’s dispatch_skill validation error had left a ToolResult in history, which flips every later turn into restrictive salvage mode, which requires an “I’ll call X:” prelude the bare hallucination doesn’t have. The rescue was starving on provenance grounds. The fix: when the entire message is one bare JSON object, there’s no surrounding prose to attribute from — so exempt that shape from the prelude requirement. Prose-embedded JSON still needs the prelude (R2 defense intact).

Fix 3 — num_ctx, third occurrence (dcbb642)

Re-deployed, re-ran — now the rescue fired, but the dispatched /joke came back “Couldn’t reach the local model.” The prompt-skill path’s ollama_chat sent no num_ctx, so it asked Ollama for the model at a different context size than the warm chat instance — on the RAM-tight CPU box that 503s. This is the same keepwarm bug class (Day 33’s eviction was occurrence #2). Pinned num_ctx (env-overridable, else 8192) on the skill path too.

The payoff

"hello there"   → "I'm doing well, thanks for asking..."
"tell me a joke" → "A man walked into a library and asked for books
                    on Pavlov's dogs and Schrödinger's cat. The
                    librarian said: it rings a bell, but I'm not sure
                    if it's here or not."

Numbers worth keeping

  • Three deploy-and-reverify cycles on the real box, not just unit tests — each fix surfaced the next bug
  • 8 new rescue tests + the num_ctx pin tests
  • 5,530 tests green across the suite

AHAs

  • The same bug class will knock three times. num_ctx mismatch has now bitten keepwarm (ping), then the skill path — and the only reason it was fast to spot the third time is the first two were documented. Write the lesson down or re-debug it.
  • A correct security refusal can still produce a broken UX. Pitts was right to reject the lift; the bug was that nothing caught the fallout downstream. The fix wasn’t to weaken the guard — it was to add a recovery path that inherits the same guard.
  • Deploy-and-reverify on the actual constrained box beats green unit tests. Two of the three bugs were invisible until the real archive showed the turn history.

Tomorrow

The chat surface is fast and correct. Push the README sweep, flip the repo public, and get back to DS.1.