A CPU-first, text-only agent specialised for tool usage and function calling in conversation.
imagine is a fork of Qwen3.5-0.8B (Apache-2.0),
reduced and specialised for one job: being a genuinely capable small agent that dispatches tools
well, on a CPU, with no GPU anywhere in the runtime path.
It is not a router. A router punts every question to a tool and waits. imagine is meant to
already have an opinion, call a tool to sharpen or confirm it, and keep being useful when the tool
returns nothing, errors, or needs synthesising. Conversational capability is an asset we protect,
not overhead we strip.
v0.2.0 — identity LoRA. First artifact with modified weights. imagine now knows what it is
without being told, and no longer credits Meta or OpenAI for building it.
imagine.stage identity-lora
imagine.weights_modified true
imagine.train_run identity-v2-step30
| v0.1.0 | v0.2.0 | |
|---|---|---|
| identity (14 held-out probes) | 0/14 | 14/14 |
| stale "I am Qwen3.5" | 14 | 0 |
| credits wrong company | 2 | 0 |
| degenerate output | 0 | 0 |
| agrees with false premise | — | 0 |
| conversational restraint | 4/4 | 4/4 |
| drift probes | 2/2 | 2/2 |
| usable tool calls | 2/7 | 2/7 |
Trained CPU-only: LoRA r=16, 6.39M trainable (0.84%), 152 rows, 33 min on 2 vCPU.
What it cost. The short-form preservation slice fixed the degeneracy but introduced a
terseness bias. Some answers improved ("I don't have access to that." instead of a rambling
paragraph); one regressed — asked to look something up in memory, it now sometimes replies
"I'm sorry, I'm not remembering that." instead of calling recall_context. Net tool
performance is unchanged at 2/7.
v0.2.0-rc1 was rejected, and the rejection is the useful part. Its corpus was 3.12:1 identity-to-preservation and every preservation prompt was long-form "explain X". It scored a perfect 14/14 identity while answering "Are you Gemini?" with "Yes. I imagine you're imagine." — agreeing it was Gemini. Preservation data must cover the response shapes at risk, not just the subject matter. v0.2.0 adds 48 short-form and 24 yes/no examples and drops the ratio to 1.05:1.
Baseline on the stock upstream weights, Q4_K_M, 2 vCPU / no GPU, llama.cpp b1-6ea215d:
| metric | value |
|---|---|
| max RSS @ 4096 ctx | 873 MB |
| prompt throughput | 82.7 tok/s |
| generation throughput | 25.3 tok/s |
| cold load → first answer | 3.6 s |
| GGUF size (Q4_K_M) | 532 MB |
| tensors | 320 (text-only; no vision, no MTP) |
These are 2-core numbers. The reference hardware contract is 4 cores / 1 GB / no GPU. Contract verification happens on target hardware, not here.
Graded by the hardened harness, which asserts arguments and not merely tool names. The v0.1.0 release notes reported "tool selection 3/5" — that number came from a tool-name-only assertion and was too kind. Corrected figures:
| axis | result |
|---|---|
| Conversational restraint (should NOT call) | 4/4 |
| Recovery after tools (empty / error / synthesise) | 3/3 |
| Tool invention (drift probes) | 0 invented |
| Dispatch a usable call (right tool AND valid arguments) | 2/7 |
| Repair an argument after a parse error | 0/1 |
Everything the base model was already good at — conversation, restraint, recovery — held at 9/9. Everything to do with constructing a correct call is where it falls down.
The two failures worth knowing about:
-
SQL where NQL is required. Asked to query NEDB, it picks the right tool and emits
SELECT COUNT(*) FROM orders WHERE status = 'paid'. Reproducible across cases. The grammar cannot stop this: llama.cpp constrains a"type": "string"argument to any text, and JSON Schema has no way to say "this string must be NQL." -
It does not repair, it escapes. Handed an NQL parse error with the correct grammar inline, it did not retry the query tool. It called
shell.runwithnql -e "SELECT COUNT(*) FROM orders WHERE status = 'paid'"— same SQL, wrapped in a shell command, invoking a CLI that does not exist. Ignored the correction, escalated to a more powerful tool, and invented an interface.
Failure 2 is the more serious one, and it sets the roadmap: the base model's conversation and restraint need preserving, while argument construction and error repair need teaching.
call-06 ("in the agent database, show me the 5 most important memories") is graded WRONG_TOOL for
choosing recall_context over nedb.query. That is arguably a defect in the case, not the model —
"memories" genuinely overlaps both tools. It is left in and flagged rather than quietly deleted,
because deleting inconvenient cases is how a harness starts lying again.
Identical weights (stock v0.1.0), identical questions, only the tool schemas changed:
| schema | usable calls (right tool AND valid arguments) |
|---|---|
free-text {"db":"string","nql":"string"} |
2/7 |
| typed slots (enums + integers) | 5/7 |
| typed slots + when-to-use descriptions | 6/7 |
No training. No weight changes. No regression on the conversational axes.
For contrast, two full LoRA runs — about 80 minutes of CPU training — moved this number by zero and introduced two behavioural regressions.
The mechanism is mechanical, not stylistic. llama.cpp constrains a "type": "string"
parameter to any text, so a model asked for NQL emits SQL and the grammar cannot object.
Enums and integers are schema-constrained, so removing the free-text DSL field makes
a wrong value unrepresentable rather than something you catch afterwards.
Full write-up and the six rules: TOOL_DESIGN.md. Enforced by a linter:
python3 tools/lint_tools.py bench/tools.json # 1 error, 10 warnings
python3 tools/lint_tools.py bench/tools_typed_v2.json # 0 errors, 2 warningsA knowledge source. Asked to compare a B-tree and an LSM tree, it calls a B-tree "a binary tree where every node has at most two children" — which is wrong, and was equally wrong before any fine-tuning. This is a 0.8B model; treat its unaided recall as unreliable. Its job is to hold a conversation and dispatch tools that fetch correct answers, not to be the answer.
Qwen3.5's native tool-call format is sentinel-delimited, not JSON:
<tool_call>
<function=recall_context>
<parameter=query>
a multi-line value needs no escaping
</parameter>
</function>
</tool_call>
Payloads are extracted by delimiter and never re-parsed as code, so quotes, braces and newlines cannot corrupt them. This makes multi-line arguments — a diff, a query, a prompt — work natively, and makes grammar-constrained decoding easier than it would be over JSON.
Upstream ships 488 tensors: 320 text LM, 153 vision, 15 multi-token-prediction. In GGUF form the
vision tower is already a separate mmproj-*.gguf, so a text-only runtime simply does not load it.
Stripping vision costs nothing and requires no weight surgery.
The 15 MTP tensors are a built-in speculative-decoding draft head. They are not reflexively discarded — on CPU that is potentially free throughput, and it is measured before it is cut.
bench/ harness — tool schemas, cases, runner, validators, identity + self-ref evals
train/ corpus builder, LoRA trainer, checkpoint screen, merge/convert pipeline
tools/ reskin.py (GGUF identity + provenance), lint_tools.py (schema linter)
# 1. build llama.cpp (CPU, no web UI)
cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON -DLLAMA_BUILD_UI=OFF
cmake --build build -j --target llama-cli llama-server llama-bench llama-quantize
# 2. reskin
python3 tools/reskin.py Qwen3.5-0.8B-Q4_K_M.gguf imagine-0.8b-Q4_K_M.gguf --version v0.1.0
# 3. serve
llama-server -m imagine-0.8b-Q4_K_M.gguf --jinja -rea off -c 8192 -t 4
# 4. baseline
python3 bench/run_baseline.py --url http://127.0.0.1:8080Weights ship as GitHub release assets, never in git history. Tooling ships as packages. One aligned version across everything. Never retag — bump.
Two licences, one bright line between them.
| what | licence |
|---|---|
| Code — reskin tooling, harness, grammar/validation, runtime | BUSL-1.1, Change Date 2030-08-04, Change License Apache-2.0 |
| Weights — GGUF release assets | Apache-2.0, matching upstream |
The weights derive from Qwen3.5-0.8B (Apache-2.0), so they ship Apache-2.0 with upstream
attribution preserved both in NOTICE and embedded in every artifact's GGUF metadata
(general.license, general.base_model.0.*). BUSL does not reach them, and the LICENSE says so
explicitly — nothing here restricts a right you already hold in the weights under Apache-2.0.
BUSL-1.1 converts to Apache-2.0 on the Change Date. Open source on a timer, not open source withheld.