Files
apress/deploy/uno-q/LOCAL-FALLBACK.md
T
Omar SobhandClaude Opus 4.8 37a54521e8 docs(uno-q): record the offline capability boundary
Measured on the board 2026-07-20. Offline is limited by capability, not only
by speed:

  simple tool call (i2cdetect, 708 tok)  -> works, 20s, real answer
  write+compile+flash a sketch (1997 tok) -> never completed in 450s, two
                                             identical requests, no tool call

So with no network the node can sense and decide, but cannot author new
code - that needs the cloud model. This is the concrete degradation boundary
students are asked to state in ADD Layer 4: what survives an outage is the
loop over already-flashed firmware, not writing new firmware.

Also corrects the cold-load figure. The documented 3-5 minutes was measured
against the 1.1GB coder model; the 409MB qwen.gguf we standardised on came
up healthy in ~5s from a cold boot.

Notes that BuildFlash routes to the cloud agent, so the student build/flash
exercise does not sit behind this boundary.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 09:09:48 -07:00

16 KiB
Raw Blame History

Local fallback — what we run, and why not LiteRT

How an APESS Uno Q node answers when the cloud is slow, rate-limited, or gone. Supersedes the "LiteRT-LM on UNO Q 4GB — Technical Spike Plan v1.0" (July 2026), which was never executed. See Why not LiteRT for the decision, and Lessons carried forward for the design constraints it got wrong.


1. What we actually run

Cloud-first, on-board Qwen as fallback. The local model is a safety net for a guaranteed-offline condition — not the primary reasoner.

Agent → provider routing

From config.template.toml:

alias provider behaviour
default custom.claude cloud first, fallback = ["llamacpp.local"] — the workshop default
cloud custom.cloud cloud only, no fallback
local llamacpp.local on-board Qwen only (fully offline)
chaos custom.dead points at a dead :9099 so it always fails over — the outage demo

chaos exists to make resilience demonstrable on command: nothing listens on 127.0.0.1:9099, so any prompt routed there is guaranteed to fall through to the local model. It is the only honest way to show failover without unplugging WiFi.

The fallback = ["llamacpp.local"] array on custom.claude and custom.dead is the only fallback configured on the board. There is no multi-cloud chain locally.

Local serving

From systemd/zeroclaw-llama.service:

llama-server -m /home/arduino/models/qwen.gguf \
  --host 127.0.0.1 --port 8083 -np 1 -c 8192 --jinja --mlock
  • Persistent server, loopback-only. The model is loaded once and stays resident. It is never spawned per request.
  • -np 1 is mandatory — a larger slot count makes the tiny model 500 on context.
  • --mlock pins the weights so they aren't swapped out under memory pressure.
  • Health at :8083/health. The ZeroClaw daemon is on :8080 and is ordered After=zeroclaw-llama.service.

Config drift — repo vs. dev board (2026-07-19)

The dev board (serial 65301572) had been hand-edited away from the repo and never propagated back. Benchmarking settled it: the repo was right.

Repo before Board before Both now
Model qwen.gguf qwen-coder.gguf (1.1 GB) qwen.gguf (409 MB)
Context -c 16384 -c 8192 -c 8192

Model — resolved in the repo's favour. The board ran Qwen2.5-Coder-1.5B, which is both larger and specialized for the wrong task. Swapping to Qwen2.5-0.5B-Instruct made tool calls ~6× faster (24 s → 3.8 s warm) at 6/6 correctness, and freed ~700 MB. A pre-swap backup sits at ~/zeroclaw-supervisor.sh.bak-preswap on the board.

Context — resolved in the board's favour: standardised on -c 8192 across zeroclaw-llama.service, zeroclaw-supervisor.sh, and provision-uno-q.sh. At ~17 tok/s prefill, filling even an 8k window costs ~8 minutes, so a 16k window was capacity that could never be afforded and doubled KV footprint for nothing.

Gotcha for whoever changes this: the supervisor reads MODEL into a shell variable at launch. Editing the file does not affect the running process — you must restart the supervisor itself, not just llama-server, or it will relaunch the old model.

Keeping it alive

zeroclaw-supervisor.sh is the no-root watchdog for boards where systemd isn't usable. Two properties matter:

  • It polls /health endpoints, not pgrep — a wedged process passes pgrep but fails a health check. Process liveness is not service liveness.
  • It respects a warmup grace (LLAMA_WARMUP=300); reaping mid-load produces an infinite restart loop that never converges. The 35 minute cold-load figure was measured against the 1.1 GB coder model — the 409 MB qwen.gguf we standardised on came up healthy in ~5 s from a cold boot (2026-07-20). The 300 s grace is now generous rather than necessary, which is harmless.

Children start under setsid so they survive the launching shell closing — plain nohup … & inside an adb shell does not give you that, which is why services died on a previous USB disconnect.

Workshop-proxy variant

../workshop-llm/zeroclaw-board.toml points the cloud agent at a LiteLLM proxy on :4000. The board asks for one model name (workshop) and never sees the routing: the proxy pools Kimi + GLM and falls back to Groq → Gemini. Cloud-side fallback lives in litellm-config.yaml, not on the board.

Secrets

Provider config is file-based; the only ZEROCLAW_-prefixed variable in the repo is ZEROCLAW_BIN (a binary path). The Claude Max setup-token is env-only at runtime and must never be written to disk or committed — not into config.toml, not into a unit file, not into a provisioning script. Same for attendee virtual keys and paired tokens.


2. Why not LiteRT

The spike proposed LiteRT-LM + FunctionGemma 270M, invoked by shelling out to the litert-lm CLI per classification. We did not pursue it:

  • Subprocess-per-call reloads the model every request. On a quad-A53 that cost dominates the entire latency budget. We already had a persistent server working.
  • Fleet provisioning burden. It required an export pipeline (litert-torch, run off-board) plus a per-board huggingface-cli login with a personal token.
  • The strategy inverted. The spike framed the local model as a primary classifier. The shipped decision is cloud-default with local reserved for guaranteed-offline — so the bar the spike was measuring against stopped being the bar.
  • Its premise was pre-pivot. Every prompt and pass gate assumed nominal/anomalous/critical structural-health classification. The workshop now teaches domain-driven design (Domain → Skills → Policies → Harness → Loops) over an open, team-chosen domain, so a fixed three-label classifier is not the target.

None of this says LiteRT is a bad runtime. It says it solved a problem we'd already solved, in a shape that cost more to operate.


3. Measured baseline

Measured 2026-07-19 on board 65301572 at -c 8192, warm, 4 threads (NEON + ARM_FMA + OPENMP + REPACK, n_batch=2048). Figures come from llama-server's own timings block, not wall clock. Prefix caching was deliberately defeated with a unique leading nonce, so these are honest uncached numbers — the cached case is far better and is what real workloads see (see below).

Two models compared: qwen.gguf = Qwen2.5-0.5B-Instruct (630 M params, 24 layers, n_embd 896) and qwen-coder.gguf = Qwen2.5-Coder-1.5B-Instruct (1.78 B, 28 layers, n_embd 1536).

prompt tok 0.5B prefill 1.5B-coder prefill 0.5B decode coder decode
~260 19.6 tok/s 5.30 10.9 3.5
~484 19.3 tok/s 5.28 10.0 3.3
~933 18.5 tok/s 5.14 9.4 3.0
~1833 17.1 tok/s 4.94 6.3 2.33
  • Prefill is linear in both — cost is simply proportional to context loaded.
  • The 0.5B is ~3.6× faster at prefill, ~3× at decode. That slightly exceeds the 2.8× parameter ratio because cost tracks compute per layer (layers × n_embd² predicts ~3.4×), not parameter count.
  • Decode degrades as KV grows in both (0.5B: 10.9 → 6.3 tok/s). Output cost is not constant; it worsens with how much context you loaded.
  • Full-window extrapolation (0.5B): 4k ctx ≈ 4 min, a full 8k ≈ 8 min.
  • RAM with the 0.5B resident: ~3.0 GB of 3.67 GB available. The coder model costs ~700 MB more.

Tool calling — the offline task class that matters

Warm, prefix-cached function calling on the 0.5B, stable system+tools preamble, 3 tools (record_reading, update_document, set_led), 6 requests (reproduce: bench-tools.py):

result
Correct tool + valid JSON args 6 / 6
First call (cold preamble, 397 tok) 24.3 s
Warm calls avg 3.8 s (3.04.5 s)
prompt_n warm / cache_n 14 / 377

prompt_n collapsing from 397 → ~14 once the cache fills is the whole story: the preamble prefills once, later calls pay only for the user's sentence. Calls came back as native tool_calls, not scraped from prose.

Caveats: 6 unambiguous single-tool cases is a floor check, not a ceiling — it says nothing about ambiguous or multi-step requests. And this exercised llama-server's native tool path, whereas the board config sets native_tools = false and uses ZeroClaw's text parser. The path ZeroClaw actually takes is still unvalidated.

Prefix caching is the dominant lever

The table above is the uncached worst case. In real tool-calling workloads the system prompt and tool schemas are byte-identical on every call, so llama.cpp prefills them once and later calls pay only for the delta. A ~600-token preamble + ~60-token request + ~50-token tool call is ~2 minutes uncached but ~2530 s warm.

Three ways to destroy that advantage — all easy to do by accident:

  • Varying the preamble. A timestamp, session id, or rotating skill list injected into the system prompt invalidates the cache every call.
  • Unbounded max_tokens. At ~3 tok/s, a 500-token answer is 2.5 minutes of pure decode.
  • max_tool_iterations = 6. Each iteration is a full model call — ~3 min per task. Acceptable for a background loop, not for anything interactive.

The offline capability boundary (measured 2026-07-20)

Speed is not the only limit — there is a hard capability cliff. Same board, same lean profile, same on-board 0.5B:

task result
Call a simple tool (i2cdetect, 708-token prompt) works — 20 s, tool call fired, real answer
Write + compile + flash a sketch (1,997-token prompt) never completed — 450 s, two identical requests, no tool call ever emitted

So offline the node can sense and decide, but it cannot author new code. Code generation needs the cloud model. This is the concrete degradation boundary to state in ADD Layer 4: what still works with no network is the sensing and decision loop over already-flashed firmware — not writing new firmware.

Two related prompt-shape findings from the same session:

  • Imperative, not interrogative. "List the I2C devices on the bus." fires a tool call in 20 s; "What sensors can you find on the I2C bus?" produced no tool call at all in 200 s. Same agent, same 708-token prompt — phrasing was the only variable.
  • It does not hallucinate hardware. Asked to list I2C devices on a board with an empty bus, it ran the tool and reported the bus numbers rather than inventing a sensor.

Note also that BuildFlash in the SPA routes to the cloud agent, not local — so the student build/flash exercise does not depend on the boundary above.

What this means for offline work

On the 0.5B (measured or extrapolated from the curve above):

task cost verdict
Function/tool call (warm, cached preamble) 3.8 s (measured) viable, near-interactive
Process a result → decide → emit ~46 s viable
Update / retag / summarize one short doc ~2535 s viable
Q&A retrieving several notes (1.5k ctx) ~1.5 min marginal, usable offline
Code generation not viable (quality, not speed)

An 8-hour overnight loop processes roughly 500k prefill tokens on the 0.5B — several hundred to a thousand short notes.

The offline task class Omar specified — function calls, processing a specific result, updating documents, nothing needing complex reasoning — is comfortably served by the 0.5B. Sub-4-second tool calls are fast enough that this is not merely a background queue worker.

⚠️ The harness, not the model, is the bottleneck

Raw model speed is not what makes or breaks offline. Prompt size is. Measured 2026-07-19 driving the real ZeroClaw agent loop (native_tools = false, the text parser):

default profile lean offline profile
Prompt tokens 4,718 706 (85%)
Prefix-cache match (sim_best) 0.435 0.966
Tokens re-prefilled when warm ~2,600 24
Cold turn cancelled mid-prefill ~29 s
Warm turn never completed 11 s
Tool actually fired no yes

The 11 s warm figure is a complete agentic turn — two LLM calls plus tool execution — ending in a real answer ("The I2C devices on the bus are: i2c-0, i2c-1, and i2c-2.").

The text parser was never the problem. With the default profile the agent sent a 4,718-token prompt that took ~4.6 min to prefill at 17 tok/s, so the client cancelled before the model could respond. Disabling skills injection alone made it worse (10,901 tokens — past the 8k window, hard failure) because 84 accumulated memories in brain.db flooded the prompt. Flipping native_tools = true changed nothing (still 4,718): tool schemas were never the bulk.

The lean offline profile

Everything that controls prompt size lives on the runtime profile, not the agent (effective_skills_prompt_mode, schema.rs:4192, lets a runtime profile override the global [skills] prompt_injection_mode):

[runtime_profiles.offline]
agentic = true
max_tool_iterations = 3
compact_context = true              # 6000 chars / 2 RAG chunks; also drops Channel Capabilities
prompt_injection_mode = "compact"
max_system_prompt_chars = 2000
max_context_tokens = 3000
max_history_messages = 2
memory_recall_limit = 1
parallel_tools = false

[risk_profiles.sense_only]          # narrow tools ⇒ also drops the hardware block +
level = "supervised"                # Uno-Q flash imperative from the system prompt
allowed_tools = ["i2cdetect"]
auto_approve = ["i2cdetect"]

[agents.sense]
model_provider = "llamacpp.local"
risk_profile = "sense_only"
runtime_profile = "offline"
skill_bundles = []                  # the 12 bundled skills were most of the 4,718
mcp_bundles = []

Two zero-value footguns:

  • memory_recall_limit = 0 means unlimited (usize::MAX), not disabled. Use 1.
  • max_actions_per_hour = 0 is a hard zero that blocks everything; unlimited is u32::MAX.

Per-step prompts for multi-step operations

Cron JobType::Agent carries its own prompt, allowed_tools, and uses_memory per job (crates/zeroclaw-runtime/src/cron/types.rs:32,143), bound to an agent via AliasedAgentConfig.cron_jobs. So a multi-step offline operation is a sequence of jobs, each with the prompt and tool surface for its own step — and uses_memory = false skips memory injection entirely for steps that don't need it. Agent-to-agent delegates is the other path.


4. Lessons carried forward

Design constraints the spike document got wrong. Worth keeping even though the spike itself is dead.

  • A fail-safe must never be "nominal". The spike degraded both error paths to a synthetic nominal response. In any monitoring context that is a silent all-clear manufactured by an outage. Degrade to unknown / escalate — never to "fine".
  • Fallback must trigger on more than "network unavailable". Timeouts, 429s, DNS failures, and auth errors are the realistic degradations — a flaky workshop LAN far more often than a cleanly absent one. Matching only a NetworkUnavailable variant routes the common cases straight past the local model.
  • Health-check endpoints, not processes. A wedged process passes pgrep.
  • Persistent server, never per-call subprocess. Model load time dominates.
  • Check exit status and stderr on any subprocess. Otherwise a crashed child surfaces as an unrelated JSON parse error, which is miserable to debug on a board.
  • Don't gate on accuracy against undefined thresholds. The spike asked a model to choose among three labels without ever defining their boundaries, then scored it against the author's own intuition. That measures prompt underspecification, not model capability. Define the thresholds or don't call it accuracy.

See also