@qvac/harness 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/asana/SKILL.md +86 -0
- package/.claude/skills/gh-stack/SKILL.md +181 -0
- package/.claude/skills/gh-stack/references/commands.md +177 -0
- package/.claude/skills/gh-stack/references/stack-design.md +97 -0
- package/.claude/skills/gh-stack/references/troubleshooting.md +159 -0
- package/.claude/skills/js-house-style/SKILL.md +671 -0
- package/.claude/skills/kvcache/SKILL.md +171 -0
- package/.claude/skills/pr-create/SKILL.md +180 -0
- package/.claude/skills/pr-review/SKILL.md +217 -0
- package/CLAUDE.md +39 -0
- package/README.md +26 -4
- package/dist/harness.bundle +8 -60
- package/dist/prebuilds-manifest.json +0 -8
- package/dist-lib/index.d.ts +4 -1
- package/dist-lib/index.js +23 -7
- package/dist-lib/lib/docx/index.d.ts +5 -0
- package/dist-lib/lib/docx/index.js +49 -0
- package/dist-lib/lib/models/model-pool.d.ts +1 -1
- package/dist-lib/lib/models/model-pool.js +3 -2
- package/dist-lib/lib/spawn.d.ts +1 -0
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/wheels.js +2 -1
- package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +1 -1
- package/dist-lib/schema/generate.d.ts +6 -2
- package/dist-lib/schema/generate.js +10 -5
- package/docs/decisions/2026-05-29-kvcache-tool-turns.md +75 -0
- package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +127 -0
- package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +126 -0
- package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +88 -0
- package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +79 -0
- package/docs/decisions/2026-08-11-pyodide-worker-thread.md +68 -0
- package/docs/decisions/2026-08-17-harness-hook-pipeline.md +84 -0
- package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +176 -0
- package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +95 -0
- package/docs/decisions/2026-08-20-kvcache-static-placement.md +109 -0
- package/docs/decisions/2026-08-21-vendored-python-wheels.md +85 -0
- package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +64 -0
- package/docs/decisions/2026-08-27-writable-skills-overlay.md +56 -0
- package/docs/decisions/2026-09-01-skill-operation-manifests.md +53 -0
- package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +78 -0
- package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +136 -0
- package/docs/decisions/INDEX.md +38 -0
- package/docs/decisions/TEMPLATE.md +32 -0
- package/docs/kvcache-reference.md +655 -0
- package/package.json +6 -2
- package/worker/harness-sidecar.mjs +3 -2
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
# KV cache — reference
|
|
2
|
+
|
|
3
|
+
How the QVAC SDK's prompt cache actually works, and the rules for using it from
|
|
4
|
+
core-v2. Mechanics only.
|
|
5
|
+
|
|
6
|
+
> **"core-v2" is this stack**: `@qvac/harness` (completion, tools, the model
|
|
7
|
+
> pool — this repo) plus `@qvac/core` (chat, storage, compaction). The name
|
|
8
|
+
> distinguishes it from the retired v1 assistant that still sits in
|
|
9
|
+
> `tetherto/qvac-app`. Paths below are repo-relative when they are harness
|
|
10
|
+
> files, and named by package when they are not. A handful of
|
|
11
|
+
> `qvac-assistant/*` specifiers are the desktop app's own aliases and live in
|
|
12
|
+
> `tetherto/qvac-app`.
|
|
13
|
+
|
|
14
|
+
The sliding window and dynamic tools this doc once covered are retired on every
|
|
15
|
+
layer of the stack; what remains of them is one section,
|
|
16
|
+
[Retired knobs](#retired-knobs). The remaining work items this implies are the
|
|
17
|
+
"KV cache follow-ups" checklist in `assistant/MIGRATION.md`, which is still in
|
|
18
|
+
`tetherto/qvac-app`.
|
|
19
|
+
|
|
20
|
+
## Versions
|
|
21
|
+
|
|
22
|
+
Behaviour here is version-specific and has changed more than once. Check first:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
grep '"@qvac/sdk"' package.json
|
|
26
|
+
grep '"version"' node_modules/@qvac/inference/package.json node_modules/@qvac/llm-llamacpp/package.json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
core-v2 ships **`@qvac/sdk` 0.19.0** → **`@qvac/inference` 0.19.0** →
|
|
30
|
+
**`@qvac/llm-llamacpp` 0.49.1** (`@qvac/harness` declares `^0.19.0` for
|
|
31
|
+
both packages; inference pulls `^0.49.1`). If the lockfile resolves more than
|
|
32
|
+
one copy, read the one `@qvac/harness` gets, not whichever is hoisted.
|
|
33
|
+
|
|
34
|
+
Since 0.19 `@qvac/sdk` is a thin re-export over `@qvac/inference`, which owns
|
|
35
|
+
the engine, the plugins and the cache session. The harness runs in a Bare
|
|
36
|
+
sidecar with the engine in-process, so its `#sdk` import seam resolves to
|
|
37
|
+
`@qvac/inference` there (`@qvac/sdk` only under Node) — the cache code that
|
|
38
|
+
executes is `@qvac/inference/dist/plugins/builtin/llamacpp-completion/ops/`.
|
|
39
|
+
|
|
40
|
+
What 0.19 changed for everything below ([qvac#4163](https://github.com/tetherto/qvac/pull/4163)):
|
|
41
|
+
`n_discarded` is gone from the `loadModel` schema and rejected at validation;
|
|
42
|
+
the `toolBlockEvictable` gate that re-sent the tool block under sliding is
|
|
43
|
+
deleted; a thrown `ContextOverflowError` no longer rolls the cache back; and the
|
|
44
|
+
error carries `requiredTokens`/`cachedTokens`/`ctxSize`, parsed from every
|
|
45
|
+
wording the addon emits. Addon 0.48 removed sliding outright (and
|
|
46
|
+
`contextSlides`), stops a generation that fills the window with
|
|
47
|
+
`stopReason=contextOverflow`, and rewrites reasoning compaction as rewind and
|
|
48
|
+
re-decode. Earlier revisions of this doc described 0.18.2 (rollback on overflow,
|
|
49
|
+
sliding still possible) and 0.17.x (tool block in the prime); both are history.
|
|
50
|
+
Read source in this order:
|
|
51
|
+
|
|
52
|
+
1. `node_modules/@qvac/inference/dist/**` — compiled but readable, and exactly
|
|
53
|
+
what runs. `@qvac/sdk/dist` holds only the re-export and the RPC client.
|
|
54
|
+
2. A checkout of the `qvac` monorepo (`packages/inference`,
|
|
55
|
+
`packages/llm-llamacpp`) — **fetch first and read `tether/main`.** A stale
|
|
56
|
+
checkout produced two wrong conclusions in this doc's history. The addon C++
|
|
57
|
+
is `packages/llm-llamacpp/addon/src/model-interface/`.
|
|
58
|
+
3. Never a standalone `qvac-lib-infer-llamacpp-llm` checkout — the addon source
|
|
59
|
+
of record is the monorepo; standalone clones drift stale.
|
|
60
|
+
|
|
61
|
+
The live C++ class is **`MtmdLlmContext`**, not `TextLlmContext`: chat models load
|
|
62
|
+
with a projection model, so the multimodal context is what executes. The two are
|
|
63
|
+
parallel implementations.
|
|
64
|
+
|
|
65
|
+
## The three layers
|
|
66
|
+
|
|
67
|
+
Three state sets, keyed differently, never reconciled with each other:
|
|
68
|
+
|
|
69
|
+
| Layer | Owns | Keyed by |
|
|
70
|
+
| ------- | -------------------------------------------------- | --------------- |
|
|
71
|
+
| core-v2 | the message **list** | chat |
|
|
72
|
+
| SDK | a message **count** (`savedCount`) | cache file path |
|
|
73
|
+
| addon | token **positions** (`nPast`) + the on-disk `.bin` | cache key |
|
|
74
|
+
|
|
75
|
+
`savedCount` means _"already sent"_, never _"still resident"_: nothing below the
|
|
76
|
+
SDK reports what it evicted. That is what made both retired knobs fatal, and it
|
|
77
|
+
is the test any new eviction idea has to pass.
|
|
78
|
+
|
|
79
|
+
## The knobs
|
|
80
|
+
|
|
81
|
+
| Knob | Where | core-v2 call site |
|
|
82
|
+
| ----------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `history` | `completion()` | full history, no pre-trim |
|
|
84
|
+
| `kvCache: 'chat-<id>'` (a repair generation nests under it: `chat-<id>/r<n>`) | `completion()` | [assistant.ts](https://github.com/tetherto/qvac-core/blob/main/lib/assistant.ts) `_execute` → [completion.ts](../lib/capabilities/completion.ts) `completion` |
|
|
85
|
+
|
|
86
|
+
Nothing else. The model loads with `tools: true` and the cache-relevant config
|
|
87
|
+
(`ctx_size`, `cache-type-k/v`); every other cache behaviour is the SDK's own.
|
|
88
|
+
|
|
89
|
+
## Retired knobs
|
|
90
|
+
|
|
91
|
+
core-v2 stopped passing both on 2026-08-20
|
|
92
|
+
([2026-08-20-kvcache-static-placement](./decisions/2026-08-20-kvcache-static-placement.md)),
|
|
93
|
+
and upstream has since deleted them. They stay here as bug shapes, not options.
|
|
94
|
+
|
|
95
|
+
- **`n_discarded` (sliding window).** Was `floor(ctxSize / 10)` at `loadModel()`.
|
|
96
|
+
SDK 0.19 dropped it from the config schema, so `loadModel` rejects the key;
|
|
97
|
+
addon 0.48 removed sliding entirely
|
|
98
|
+
([qvac#3938](https://github.com/tetherto/qvac/pull/3938)). Two present-day
|
|
99
|
+
defects retired it before upstream did. A slide was **invisible to the SDK**:
|
|
100
|
+
the session recorded a turn's full message count on success and next sent
|
|
101
|
+
`history.slice(savedCount)`, so a span the addon discarded in C++ was gone from
|
|
102
|
+
the live cache and the saved `.bin` while the app still counted it as sent —
|
|
103
|
+
the model silently lost the middle of the chat, with no error and no TTFT
|
|
104
|
+
tell. And while sliding was possible the SDK set
|
|
105
|
+
`toolBlockEvictable = n_discarded > 0`, which made the whole tool block travel
|
|
106
|
+
on every turn (~331 tokens for a 4-tool agent) instead of being cached once.
|
|
107
|
+
Prefill also slid exactly once and gave up, so long prompts overflowed anyway.
|
|
108
|
+
A full window is now a normal end state: overflow at prefill, `stopReason:
|
|
109
|
+
length` mid-generation, recovered by the harness ladder and by the fold.
|
|
110
|
+
- **`toolsMode: 'dynamic'` (dynamic tools).** SDK 0.18 deleted the key
|
|
111
|
+
([qvac#3380](https://github.com/tetherto/qvac/pull/3380); the parser rejects
|
|
112
|
+
it, so passing it fails every load) and the addon dropped `tools_compact`
|
|
113
|
+
after 0.39.4 ([qvac#3373](https://github.com/tetherto/qvac/pull/3373)). It
|
|
114
|
+
was a silent no-op on every model we ship: the addon gated `tools_compact` on
|
|
115
|
+
`general.architecture == "qwen3"` exactly, our catalog is `qwen35`/`qwen36`,
|
|
116
|
+
so it logged `tools_compact is not supported … ignoring` and ran without
|
|
117
|
+
anchoring or trimming — while the SDK still sliced as-if-dynamic from the
|
|
118
|
+
requested flag, sending `[assistant, user]` plus the tool block and ignoring
|
|
119
|
+
`savedCount`. The previous answer was duplicated into the KV every turn, the
|
|
120
|
+
block accumulated, and on a cold cache only the last exchange was sent, so the
|
|
121
|
+
model confabulated instead of overflowing. Where the addon half did run it
|
|
122
|
+
trimmed the tool block, every tool result and the assistant's answer after a
|
|
123
|
+
turn, which is what the SDK's re-send assumed. Static placement is the only
|
|
124
|
+
mode now: `history.slice(savedCount)`, full history on a cold cache at TTFT
|
|
125
|
+
cost only — and that is what makes the overflow ladder effective, since
|
|
126
|
+
dropping oldest turns changes what the slice sends.
|
|
127
|
+
|
|
128
|
+
Each removal changed `modelConfig`, so each re-keyed `modelId` and cold-primed
|
|
129
|
+
every chat once. Static tool calling is unaffected by either removal.
|
|
130
|
+
|
|
131
|
+
## Cache identity
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
{kvCacheDir}/{cacheKey}/{modelId}/{configHash}.bin # ~/.qvac/kv-cache/...
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `cacheKey` — ours: `chat-<chatId>`.
|
|
138
|
+
- `modelId` — see below.
|
|
139
|
+
- `configHash` — `sha256(canonical({ systemPrompt, tools }))[:16]`, where
|
|
140
|
+
`tools` is the **complete tool definitions** the call carries (names,
|
|
141
|
+
descriptions, parameter schemas; object keys sorted, array order kept), or
|
|
142
|
+
`[]` when the call has none. **A changing system prompt changes the file**,
|
|
143
|
+
and so does any edit to any tool — one cold prime per change. An `@skill`
|
|
144
|
+
mention changes it for that turn only.
|
|
145
|
+
|
|
146
|
+
Two `.bin` files under one chat key means `configHash` moved.
|
|
147
|
+
|
|
148
|
+
### What `modelId` actually is
|
|
149
|
+
|
|
150
|
+
The SDK's `modelId` is **not** a model name — it is identity-by-config, computed
|
|
151
|
+
at `loadModel` (`@qvac/inference`, `dist/handlers/load-model/handler.js`,
|
|
152
|
+
`canonicalConfigString` + `generateShortHash`):
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
modelId = sha256("{modelType}:{modelSrc}:{canonicalConfigString(modelConfig)}").slice(0, 16)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`canonicalConfigString` recursively sorts keys, so key _order_ never matters —
|
|
159
|
+
but **every key-value pair in `modelConfig` does**. Two loads of the same weights
|
|
160
|
+
with any differing config field are two different `modelId`s: separate pool
|
|
161
|
+
entries in memory, separate `{modelId}/` cache directories on disk.
|
|
162
|
+
|
|
163
|
+
Properties that follow:
|
|
164
|
+
|
|
165
|
+
- **Deterministic.** Same type + src + config → same id, across restarts. That is
|
|
166
|
+
what lets `chat-<id>/{modelId}/` cache files survive an app restart, and it is
|
|
167
|
+
why `loadModel` on an already-loaded id is a no-op ("already loaded").
|
|
168
|
+
- **The SDK cannot re-configure a loaded model.** Changing ctx or KV quant means
|
|
169
|
+
loading a _different_ `modelId`; core-v2's model pool keys on exactly this.
|
|
170
|
+
|
|
171
|
+
### What happens when you change …
|
|
172
|
+
|
|
173
|
+
| You change | `modelId` | `configHash` | Cache effect |
|
|
174
|
+
| -------------------------------------------------------------------------------------------------------- | ----------- | ------------ | ---------------------------------------------------------------------------------------- |
|
|
175
|
+
| system prompt (in history) | same | **changes** | new `.bin` in the same dir; cold prime; old file orphaned |
|
|
176
|
+
| tool set — add, drop, or edit any tool's name, description or parameters | same | **changes** | new `.bin`; cold prime |
|
|
177
|
+
| **any** `modelConfig` field (ctx_size, `cache-type-k/v`, device, gpu_layers, stop sequences, verbosity…) | **changes** | same | new `{modelId}/` directory — **every chat on this model cold-primes**; old dirs orphaned |
|
|
178
|
+
| model weights (`modelSrc`) | **changes** | — | same as above |
|
|
179
|
+
| chat | — | — | different `{cacheKey}/` directory (by design) |
|
|
180
|
+
|
|
181
|
+
Two traps this table encodes:
|
|
182
|
+
|
|
183
|
+
- **Observing the system changes it.** core-v2 puts `verbosity: llmVerbosity()`
|
|
184
|
+
into `modelConfig` ([model-pool.ts:330](../lib/models/model-pool.ts)),
|
|
185
|
+
read from `QVAC_LLM_VERBOSITY`. Turning addon debug logging on flips a config
|
|
186
|
+
field → new `modelId` → every chat cold-primes under a fresh directory. Debug
|
|
187
|
+
the cache with harness logs (`QVAC_HARNESS_LOG`) or `xxd` on the `.bin`, not by
|
|
188
|
+
raising addon verbosity, or the cold behaviour you observe is one you caused.
|
|
189
|
+
Any env-dependent config field (`openclCacheDir`, `main-gpu`) has the same
|
|
190
|
+
property.
|
|
191
|
+
- **Orphaned files accumulate — and only the app can delete them.** Nothing
|
|
192
|
+
cleans custom-key cache directories (the retention sweep is auto-key only), so
|
|
193
|
+
every config or system-prompt change strands the previous `.bin`. The SDK
|
|
194
|
+
cannot safely auto-delete: identity-by-config cuts both ways, so a "stale"
|
|
195
|
+
file **revives** the moment the same `(key, modelId, configHash)` recurs —
|
|
196
|
+
`beginTurn` probes the disk path and reuses it (flip ctx back, revert the
|
|
197
|
+
prompt, unset `QVAC_LLM_VERBOSITY`, and the old cache is live again). Whether
|
|
198
|
+
that rare revival is worth the disk is a product call, which is why retention
|
|
199
|
+
for custom keys is deliberately the caller's job (`deleteCache({ kvCacheKey,
|
|
200
|
+
modelId? })`). core-v2 implements **delete on re-key** (2026-08-20,
|
|
201
|
+
`@qvac/core`'s `lib/kv-cache-reclaim.ts`): every device with a harness watches
|
|
202
|
+
agents and deletes the whole `chat-<id>` key for an agent's chats when a
|
|
203
|
+
re-keying field changes, deletes a chat's key when the chat disappears, and
|
|
204
|
+
deletes it again after a fold (below) — four moments in all, each of which
|
|
205
|
+
reclaims any repair generations with it, because they are children of that key —
|
|
206
|
+
the cold prime is already inevitable, so it costs nothing beyond the
|
|
207
|
+
revert-reuse case. Residual: changes replicated while the assistant was
|
|
208
|
+
closed reclaim nothing (no diff to observe). Scale that made this
|
|
209
|
+
non-optional: observed 21–26 MB per file at ctx 2048, and the state file
|
|
210
|
+
scales with the _allocated_ context — at the 51,200 desktop default,
|
|
211
|
+
plausibly hundreds of MB per chat (unmeasured).
|
|
212
|
+
- **Apps can delete caches too, device-locally.** The engine API exposes
|
|
213
|
+
`clearKvCache()` (every cache on this device) and `deleteChatKvCache({ id })`
|
|
214
|
+
(2026-09-10; the chat's key with its repair children). Both act only on the
|
|
215
|
+
device whose Core is called — nothing is replicated, and the chat's warm
|
|
216
|
+
cache may sit on the provider device. The desktop app's
|
|
217
|
+
`qvac-assistant/kv-cache` wraps both behind the `supportsKvCacheDeletion` gate.
|
|
218
|
+
|
|
219
|
+
One more footgun: the SDK also accepts `system_prompt` **inside `modelConfig`**
|
|
220
|
+
as a fallback for the prime when the history carries no system message. Putting
|
|
221
|
+
it there would drag the system prompt into `modelId` — a prompt edit would then
|
|
222
|
+
cold-prime _every chat_ instead of one. core-v2 correctly keeps it in history;
|
|
223
|
+
keep it that way.
|
|
224
|
+
|
|
225
|
+
## Building the history
|
|
226
|
+
|
|
227
|
+
The `history` argument is an ordered array of `{ role, content }` (the field is
|
|
228
|
+
`content`, not `message`; user turns may also carry `attachments`). Canonical
|
|
229
|
+
shape, mid tool-chain:
|
|
230
|
+
|
|
231
|
+
```jsonc
|
|
232
|
+
[
|
|
233
|
+
{ "role": "system", "content": "<harness buildSystemPrompt output>" },
|
|
234
|
+
{ "role": "user", "content": "question" },
|
|
235
|
+
{ "role": "assistant", "content": "answer" },
|
|
236
|
+
{ "role": "user", "content": "next question" },
|
|
237
|
+
{ "role": "assistant", "content": "<tool_call>…web_search…</tool_call>" },
|
|
238
|
+
{ "role": "tool", "content": "<tool result>" },
|
|
239
|
+
{ "role": "assistant", "content": "answer grounded in the result" }
|
|
240
|
+
]
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Structural rules:
|
|
244
|
+
|
|
245
|
+
1. **Exactly one `system` message, first.** The SDK primes from it and filters it
|
|
246
|
+
out of cold sends; a second one mid-history is never valid.
|
|
247
|
+
2. **A `tool` message directly follows the assistant message that called it.**
|
|
248
|
+
The chat template assumes it.
|
|
249
|
+
3. **The last message is `user`** (fresh turn) **or `tool`** (chain
|
|
250
|
+
continuation).
|
|
251
|
+
4. **Tool definitions are never history.** They ride the separate `tools`
|
|
252
|
+
argument.
|
|
253
|
+
5. **Pass the whole history, every turn.** The SDK computes the delta. Never
|
|
254
|
+
pre-trim — the only sanctioned shrinking is the overflow ladder, which edits
|
|
255
|
+
the _outgoing candidate_, not the stored history.
|
|
256
|
+
|
|
257
|
+
Note the two shapes core-v2 actually has: **within a run**, the round loop
|
|
258
|
+
appends `assistant(tool-call markup) → tool(result)` pairs per round. **Across
|
|
259
|
+
turns**, history is rebuilt from storage and collapses each run to
|
|
260
|
+
`[user, final assistant answer]` — thinking chunks never join assistant content,
|
|
261
|
+
and tool results are not replayed (the one exception is a `skill` load, replayed
|
|
262
|
+
so the next turn keeps the granted tools). `sanitizeTurn` then strips control
|
|
263
|
+
tokens from every turn and residual tool-call markup from assistant turns.
|
|
264
|
+
|
|
265
|
+
### Editing history: what resets the cache, and what silently doesn't
|
|
266
|
+
|
|
267
|
+
**Editing history content never resets the cache.** For our custom `chat-<id>`
|
|
268
|
+
key the cache identity is `(cacheKey, modelId, configHash)` and `configHash`
|
|
269
|
+
hashes only the system prompt and the tool definitions — history is not part
|
|
270
|
+
of it. The only edits that force a fresh cache file are a **system prompt
|
|
271
|
+
change**, a tool-set change, or any model-load-config change
|
|
272
|
+
(new `modelId`). (Auto-key mode hashes prior history, so there any edit is a
|
|
273
|
+
cache miss — core-v2 does not use it.)
|
|
274
|
+
|
|
275
|
+
That is the trap, not a safety: warm turns only ever send the tail, so an edit
|
|
276
|
+
to anything **before** the tail — removing a turn, stripping a thinking block or
|
|
277
|
+
tool output from an old assistant message, regenerating an earlier answer —
|
|
278
|
+
|
|
279
|
+
- is **silently ignored while the cache is warm**: the model keeps conditioning
|
|
280
|
+
on the original text in the KV — which is why core-v2 deletes the chat cache
|
|
281
|
+
before executing a superseding (retry/regenerate) run (2026-08-20,
|
|
282
|
+
`@qvac/core`'s `lib/kv-cache-reclaim.ts`); any new divergence writer must do the
|
|
283
|
+
same;
|
|
284
|
+
- **suddenly takes effect on the next cold prime** (restart, cancel-wipe, other
|
|
285
|
+
device), so the model's effective context changes across a restart;
|
|
286
|
+
- and **additionally corrupts the slice boundary**:
|
|
287
|
+
`savedCount` is a message _count_, so inserting or removing anything before
|
|
288
|
+
the tail shifts which messages the count refers to. The SDK detects only the
|
|
289
|
+
empty-slice case.
|
|
290
|
+
|
|
291
|
+
Edits that are safe, because they touch only what is about to be sent:
|
|
292
|
+
|
|
293
|
+
- the **last user message** — `annotateLastUserTime` does exactly this, and its
|
|
294
|
+
own comment states the rule: _"earlier turns must stay byte-identical so the
|
|
295
|
+
KV-cache prefix reuses"_;
|
|
296
|
+
- **tool results in the current chain** — they are the outgoing slice; the
|
|
297
|
+
overflow ladder's compaction relies on this.
|
|
298
|
+
|
|
299
|
+
**Compaction is the second divergence writer, and it is implemented.** A fold
|
|
300
|
+
rewrites history before the tail on purpose: `historyFromChunks` replaces every
|
|
301
|
+
turn through the boundary with one summary turn. So it does exactly what this
|
|
302
|
+
section warns about, and pays for it the same way — `compactChat` deletes the
|
|
303
|
+
chat key once the `compaction` chunk lands. The chunk is replicated, so a fold
|
|
304
|
+
written on another device reaches this one as data with no local delete behind
|
|
305
|
+
it; `_reconcileCompaction` closes that at the start of the next run, once per
|
|
306
|
+
device per fold. The folded chunks stay in the store and keep rendering: only the
|
|
307
|
+
prompt shrinks.
|
|
308
|
+
|
|
309
|
+
The general rule: **normalize deterministically, from turn 1, identically every
|
|
310
|
+
turn.** A transform applied late or conditionally (stripping thinking only
|
|
311
|
+
sometimes, truncating tool outputs after the fact) creates app-vs-KV drift that
|
|
312
|
+
is invisible warm and surfaces cold. core-v2's storage shape already conforms —
|
|
313
|
+
thinking and tool traffic never enter cross-turn history, so there is nothing to
|
|
314
|
+
strip later. If a true edit feature (edit/delete a past message) needs the model
|
|
315
|
+
to actually honour the change, it must invalidate the chat cache the way retry
|
|
316
|
+
does (`deleteKvCache('chat-<id>')` on the executing device before the next
|
|
317
|
+
completion) and pay the cold-start cost — it is not a content tweak.
|
|
318
|
+
|
|
319
|
+
## What actually gets sent
|
|
320
|
+
|
|
321
|
+
Every turn the SDK either finds the cache or **primes** it — a separate
|
|
322
|
+
`model.run(..., { prefill: true })` carrying the **system prompt alone** (since
|
|
323
|
+
0.17.1, qvac `598f1551b`, QVAC-23251; 0.17.0 primed the tool block too). Tools
|
|
324
|
+
stay out of the prime because a message list with no user turn is not a
|
|
325
|
+
renderable conversation for every chat template — Qwen3.5 raises
|
|
326
|
+
`No user query found in messages.` — and a template failure would degrade the
|
|
327
|
+
whole prefix rather than just the block.
|
|
328
|
+
|
|
329
|
+
Then it sends a _slice_, not the history (`prepareMessagesForCache`,
|
|
330
|
+
`decideCachedHistorySlice`):
|
|
331
|
+
|
|
332
|
+
| Condition | Sent |
|
|
333
|
+
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
334
|
+
| `savedCount` in `(0, history.length]`, slice non-empty | `history.slice(savedCount)` |
|
|
335
|
+
| otherwise — cold cache, or a stale count left by a cancelled turn | full history minus system; the stale count is dropped (`dropStaleSavedCount`) |
|
|
336
|
+
|
|
337
|
+
The **tool block** rides in front of the slice, never inside history. It is
|
|
338
|
+
written into the cache by the first turn that actually renders it — the SDK
|
|
339
|
+
requires a user message in that payload before believing it landed, because a
|
|
340
|
+
Qwen template anchors the block on the last user query and the addon answers a
|
|
341
|
+
missing one by re-rendering the turn with tools stripped — and skipped on every
|
|
342
|
+
later turn once `toolBlockCached` is recorded at commit. A stale-count fallback
|
|
343
|
+
re-sends it with the whole conversation. The block is never trimmed back out of
|
|
344
|
+
the cache; that is why `configHash` includes the tool definitions, so a changed
|
|
345
|
+
set lands on a fresh file rather than a warm prefix holding the old block.
|
|
346
|
+
|
|
347
|
+
Until 0.19 a second gate sat in front of the skip: `toolBlockEvictable =
|
|
348
|
+
n_discarded > 0`, because the sliding window's discard region opened exactly
|
|
349
|
+
where the block sits. While core-v2 passed `n_discarded` (until 2026-08-20) the
|
|
350
|
+
block travelled on every turn, ~331 tokens for a 4-tool agent; qvac#4163 deleted
|
|
351
|
+
the gate with the knob, and a config that still carried the key would not force
|
|
352
|
+
a resend.
|
|
353
|
+
|
|
354
|
+
`tools: []` (or none) means no block and a different `configHash` — a cold
|
|
355
|
+
prime, not a strategy switch.
|
|
356
|
+
|
|
357
|
+
## Addon semantics
|
|
358
|
+
|
|
359
|
+
Nothing evicts. Since addon 0.48 the KV holds exactly what was decoded into it
|
|
360
|
+
(minus compacted reasoning, below); a full window is terminal for the turn and
|
|
361
|
+
the core-v2 **compact** flow (see _Compaction_) is what replaces eviction.
|
|
362
|
+
|
|
363
|
+
**A prompt that does not fit is refused at prefill.** Two guards, both `>=`:
|
|
364
|
+
the slice alone against `ctx_size`, and the cached total plus the slice against
|
|
365
|
+
`ctx_size`. Either raises `ContextOverflow` with the cache untouched — there is
|
|
366
|
+
no discard step in between. The two guards, and the two context classes, format
|
|
367
|
+
their numbers differently; `parseContextOverflowMessage` normalizes every form
|
|
368
|
+
into the error's sizes (see _Reading the numbers_).
|
|
369
|
+
|
|
370
|
+
**A thrown overflow keeps the cache** (0.19, qvac#4163). The completion handler
|
|
371
|
+
marks a prefill overflow and every addon pre-mutation refusal (scheduler cap,
|
|
372
|
+
bad `json_schema`, missing media) as non-destructive: the deferred unwind runs
|
|
373
|
+
`releaseTurn`, which leaves the committed `.bin` and its `savedCount` alone, and
|
|
374
|
+
only rolls back a cache this same turn had freshly primed. Before 0.19 the unwind
|
|
375
|
+
was always `rollback`, so a legitimate tool-result overflow unlinked the chat's
|
|
376
|
+
whole file — the defect this doc used to carry as its top upstream report.
|
|
377
|
+
|
|
378
|
+
**A generation that reaches the ceiling stops, with what it produced** (addon
|
|
379
|
+
0.48: `stopReason=contextOverflow`, where earlier builds reported the
|
|
380
|
+
per-sequence limit). The SDK maps it to the public `stopReason: 'length'` and
|
|
381
|
+
does **not** commit the turn — `shouldCommitCachedTurn` refuses a
|
|
382
|
+
context-boundary stop the way it refuses a cancel or a zero-token reply — so the
|
|
383
|
+
deferred `rollback` unlinks the `.bin` and the next turn cold-primes from
|
|
384
|
+
history, the truncated answer included. Nothing throws, which is why
|
|
385
|
+
compaction's end-of-run trigger exists at all.
|
|
386
|
+
|
|
387
|
+
**Reasoning is compacted out of the KV by the addon.**
|
|
388
|
+
`generationParams.remove_thinking_from_context` defaults to `true` (addon
|
|
389
|
+
0.33.0); a closed thinking block is removed after the turn — since 0.48 by
|
|
390
|
+
rewinding to the block boundary and re-decoding what follows, with no `seq_add`
|
|
391
|
+
shifting. `cacheTokens` after a turn therefore excludes thinking, and a
|
|
392
|
+
compaction failure surfaces as an error rather than a cache that still holds the
|
|
393
|
+
trace. This is why core-v2 never has to strip thinking from history itself.
|
|
394
|
+
|
|
395
|
+
**Cache file.** Four ints of metadata, written on every committed turn:
|
|
396
|
+
`nPast`, `cacheTokens`, and two retired slots that carried the sliding window's
|
|
397
|
+
first-message counters. 0.48+ fills those by mirroring the live values —
|
|
398
|
+
`{nPast, nPast, cacheTokens, cacheTokens}` — so a still-sliding build that reads
|
|
399
|
+
the file refuses to slide instead of evicting from position 0.
|
|
400
|
+
|
|
401
|
+
**Reset happens only on key CHANGE.** Same key → no reload, no reset. Switching
|
|
402
|
+
keys saves the old cache first. Since addon 0.35.1 a same-key turn does check
|
|
403
|
+
whether the `.bin` still exists and discards its in-memory state if not.
|
|
404
|
+
|
|
405
|
+
**Disk growth is unbounded for our keys.** SDK 0.17.0 added a retention sweep
|
|
406
|
+
(size + idle budgets, mobile/desktop split, plus empty-directory pruning), but it
|
|
407
|
+
only considers entries with an `.auto-cache-<16hex>` marker — i.e. **auto keys
|
|
408
|
+
only**. A `chat-<chatId>` cache is never swept or size-capped, and every turn
|
|
409
|
+
rewrites the whole `.bin`. The upside: the sweep cannot evict a chat cache, so it
|
|
410
|
+
is not a cold-prime trigger.
|
|
411
|
+
|
|
412
|
+
## Compaction
|
|
413
|
+
|
|
414
|
+
The app-side answer to a full window, and the only sanctioned one:
|
|
415
|
+
`@qvac/core`'s `lib/compaction.ts` plus `compactChat` in its `lib/assistant.ts`.
|
|
416
|
+
It folds a chat's older turns into a single summary turn, persisted as a
|
|
417
|
+
replicated `compaction` chunk carrying `summary` and `throughId`, so every device
|
|
418
|
+
rebuilds the same prompt from chat data rather than from cache state. Decision
|
|
419
|
+
record: [2026-08-21-compaction-is-chat-data](https://github.com/tetherto/qvac-app/blob/main/docs/decisions/2026-08-21-compaction-is-chat-data.md),
|
|
420
|
+
still in `tetherto/qvac-app`.
|
|
421
|
+
|
|
422
|
+
**What it evicts is the prompt, not the KV.** The folded chunks stay in the store
|
|
423
|
+
and keep rendering; the fold then deletes the chat key so the next completion
|
|
424
|
+
primes from the compacted history.
|
|
425
|
+
|
|
426
|
+
**Only the last turn stays verbatim** (`COMPACTION_KEEP_TURNS`, 1), and it has to:
|
|
427
|
+
fold the last user message away and the model has nothing to answer. Keeping two
|
|
428
|
+
was measured as the reason a fold could free nothing — the second kept turn drags
|
|
429
|
+
its assistant answer along, which is usually the largest thing in the chat. At ctx
|
|
430
|
+
4096 that left a fold at ~3,890 of 4,096 and the next answer truncated at 90
|
|
431
|
+
generated tokens.
|
|
432
|
+
|
|
433
|
+
**A fold that would grow the prompt is refused** (`summary-not-smaller`). The
|
|
434
|
+
summary turn carries a 132-character prefix, so summarizing a couple of one-word
|
|
435
|
+
turns is always a loss; `foldReducesPrompt` compares it against the turns it
|
|
436
|
+
replaces before anything is appended or any cache is dropped. A comparison on
|
|
437
|
+
characters, never a token budget.
|
|
438
|
+
|
|
439
|
+
**A summarizer that overflows folds a narrower range.** The fold's own prompt is
|
|
440
|
+
the range it folds, so a history that no longer fits overflows the fold meant to
|
|
441
|
+
rescue it — and the ladder cannot trim it, because it is one user turn.
|
|
442
|
+
`compactChat` halves the range and retries instead of giving up, which keeps a
|
|
443
|
+
chat opened on a smaller window (a desktop chat at 51k on a phone at 12k) from
|
|
444
|
+
failing every message. A boundary no later than the previous fold's is refused
|
|
445
|
+
(`already-compacted`), so narrowing can never un-collapse an earlier fold.
|
|
446
|
+
|
|
447
|
+
**The boundary never splits a kept turn from its uploads.** An upload chunk is
|
|
448
|
+
appended before the user message it belongs to, so `compactionBoundary` walks
|
|
449
|
+
back over the uploads bound to the first kept message before it picks the point.
|
|
450
|
+
|
|
451
|
+
**A fold announces only once it is going to summarize.** The `compacting` phase is
|
|
452
|
+
written from inside `compactChat`, past the cheap refusals, so a run that gets
|
|
453
|
+
`already-compacted` never flashes the label.
|
|
454
|
+
|
|
455
|
+
**A fold summarizes in the run's own window.** The harness folds `ctxSize` into
|
|
456
|
+
the model key, so the summarizer gets the agent row with the app-level context
|
|
457
|
+
size applied — otherwise a fold evicts the resident model, reloads it at the
|
|
458
|
+
device default, and the run reloads it again.
|
|
459
|
+
|
|
460
|
+
**A fold survives a superseding run.** The reactive path folds and re-asks the
|
|
461
|
+
question with `parentId`, which opens a dead range over the sequence numbers
|
|
462
|
+
between the two run-requests — and that is exactly where the fold writes its
|
|
463
|
+
chunk. A compaction chunk is therefore exempt from the branch filters in
|
|
464
|
+
`_execute`: dropping it would un-fold the chat and the retry would re-send
|
|
465
|
+
everything.
|
|
466
|
+
|
|
467
|
+
**Floor.** `COMPACTION_MIN_CTX_SIZE` is 4,096, exported renderer-safe from the app's
|
|
468
|
+
`qvac-assistant/constants`. Below it the summary cannot pay for the ~420-token
|
|
469
|
+
prefix (system prompt + tool block, measured on a 4-tool agent) it has to share
|
|
470
|
+
the window with, so those chats stay on the overflow ladder. The context picker
|
|
471
|
+
still offers 1k and 2k, so a client must read the constant before telling a user
|
|
472
|
+
their conversation will be summarized.
|
|
473
|
+
|
|
474
|
+
**Triggers**, all on the SDK's own counts, never an estimate:
|
|
475
|
+
|
|
476
|
+
| When | Reads |
|
|
477
|
+
| ------------------- | ------------------------------------------------------------------------------------------------- |
|
|
478
|
+
| before a run | the previous round's `cacheTokens` vs its `ctxSize` |
|
|
479
|
+
| at the end of a run | this run's own numbers, because a turn can fill the window by itself |
|
|
480
|
+
| after an overflow | the harness ladder is exhausted; the fold lands and the question is re-asked as a superseding run |
|
|
481
|
+
|
|
482
|
+
`ctxSize` is per-device — mobile loads every model at 12k, desktop at its
|
|
483
|
+
per-model default — so only a turn _this_ device ran is a reading for it. A chat
|
|
484
|
+
that moved here from a phone carries a window this host does not have.
|
|
485
|
+
|
|
486
|
+
**Why the margin is 95%, and 90% at or below 4,096.** Holding back a fixed share
|
|
487
|
+
costs most where the window is largest, so the margin stays thin. What it has to
|
|
488
|
+
buy is a turn that lands _between_ the trigger and the ceiling — that is the turn
|
|
489
|
+
whose successor folds before generating, visibly and with a real post-fold
|
|
490
|
+
reading. At 4,096 a 5% margin is 204 tokens, narrower than an ordinary turn:
|
|
491
|
+
measured on a 4k chat, turns arrived at 58, 107, 147, 325, 416 and 1,267 tokens,
|
|
492
|
+
so the chat kept crossing the whole band inside one turn, truncating that answer
|
|
493
|
+
against the ceiling (`stopReason: length`) and leaving only a silent fold behind
|
|
494
|
+
it. A tenth of 4,096 is 409 tokens, which most of those turns fit inside.
|
|
495
|
+
|
|
496
|
+
**A fold leaves the chat with no occupancy reading for one turn**, and that is
|
|
497
|
+
deliberate. The number the folding run measured describes history the fold has
|
|
498
|
+
just replaced, so publishing it renders the chat as still full — that is what
|
|
499
|
+
locked the composer before this landed — and `_lastRunMetrics` would read it back
|
|
500
|
+
and fold again on the next run, since a new user turn moves the boundary and
|
|
501
|
+
`already-compacted` no longer refuses. The post-fold number does not exist until
|
|
502
|
+
the next prime: occupancy is whatever the SDK reports after it builds the prompt,
|
|
503
|
+
and deriving it here would be the chars-per-token estimate the rules forbid. So
|
|
504
|
+
`withoutOccupancy` strips `cacheTokens`/`promptTokens` from the terminal status of
|
|
505
|
+
a run that folded, and the next completed turn restores a real reading. The client
|
|
506
|
+
currently renders that absence as 0.
|
|
507
|
+
|
|
508
|
+
**A turn that fills the window ends cleanly.** The SDK reports `stopReason:
|
|
509
|
+
length` and the run completes — nothing throws — which is why the end-of-run
|
|
510
|
+
check exists at all: the reactive trigger never sees that turn. The SDK does not
|
|
511
|
+
commit that turn either (its `.bin` is unlinked, see _Addon semantics_), so the
|
|
512
|
+
fold's key delete costs nothing extra there: the next completion was going to
|
|
513
|
+
cold-prime regardless.
|
|
514
|
+
|
|
515
|
+
**Every fold announces itself.** The run status carries a `compacting` phase,
|
|
516
|
+
app-runtime passes it through to the live status, and desktop and mobile name it
|
|
517
|
+
"Compacting conversation...". That took one narrowing on the client: a
|
|
518
|
+
non-terminal run-status normally means the run restarted, so a client discards
|
|
519
|
+
whatever streamed — which is why announcing the end-of-run fold used to erase the
|
|
520
|
+
answer on screen. `chunk-to-turn` now exempts a status carrying that phase, since
|
|
521
|
+
a fold is not a new attempt. What stays conditional is _clearing_ the phase: the
|
|
522
|
+
clear rewrites the chunk into a plain non-terminal status, so it only runs for the
|
|
523
|
+
before-run fold, whose run carries on. Where a terminal status follows — the end
|
|
524
|
+
of a turn, a failed overflow — that status closes the phase.
|
|
525
|
+
|
|
526
|
+
**So the fold is shown from its chunk, not from the phase.** The `compaction`
|
|
527
|
+
chunk is already replicated and already parsed, so no new event was needed — only
|
|
528
|
+
rendering. `chunk-to-turn` keeps it as a turn of its own kind (that is what puts
|
|
529
|
+
the marker in the right place), the message list excludes it, and
|
|
530
|
+
`anchorCompactionMarkers` puts it on `thread.compactionMarkers` in the reducer,
|
|
531
|
+
anchored to the user message it precedes — never to a reply, which attempt
|
|
532
|
+
grouping collapses to its selected attempt — so neither client re-derives the
|
|
533
|
+
position and a retry cannot orphan the row. Desktop and mobile render it as a quiet centered row —
|
|
534
|
+
"Compacted session" — in the same shape as the day divider. Unlike the phase, it
|
|
535
|
+
survives: it is there when the user scrolls back.
|
|
536
|
+
|
|
537
|
+
## Reading the numbers
|
|
538
|
+
|
|
539
|
+
| Field | Means |
|
|
540
|
+
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
541
|
+
| `stats.promptTokens` | tokens in **this turn's slice** |
|
|
542
|
+
| `stats.generatedTokens` | tokens produced |
|
|
543
|
+
| `stats.cacheTokens` | **physical KV-cell usage** |
|
|
544
|
+
| `ContextOverflowError.requiredTokens` | what the failing guard needed, in `ctxSize` units — the slice alone, or cached + slice; can **equal** `ctxSize`, the guards fire on `>=` |
|
|
545
|
+
| `ContextOverflowError.cachedTokens` | the resident total on the warm-cache guard; unset on the slice-alone guard |
|
|
546
|
+
| `ContextOverflowError.promptTokens` | the slice, **only** when the guard counted tokens (TextLlm); unset when it reported KV cells/positions (Mtmd) |
|
|
547
|
+
| `ContextOverflowError.ctxSize` | the window — or the per-sequence cap, on a scheduler refusal |
|
|
548
|
+
|
|
549
|
+
All four are parsed out of the addon's message (`parseContextOverflowMessage`,
|
|
550
|
+
0.19). Since qvac#4163 every wording the addon emits parses; before that Mtmd's
|
|
551
|
+
forms matched nothing and `promptTokens` alternately meant the slice or a cached
|
|
552
|
+
total. Mtmd is the class production executes — chat models load with a
|
|
553
|
+
projection model — so expect `requiredTokens` and `ctxSize`, `cachedTokens` on a
|
|
554
|
+
warm overflow, and **no `promptTokens`**. Where a guard reports both positions
|
|
555
|
+
and KV cells the larger is taken. core-v2 reads none of them:
|
|
556
|
+
`isContextOverflowError` matches the error's `name` (`CONTEXT_OVERFLOW`) and the
|
|
557
|
+
ladder retries on shape alone.
|
|
558
|
+
|
|
559
|
+
Two traps:
|
|
560
|
+
|
|
561
|
+
- `cacheTokens` is a _different quantity_ from `nPast` (logical positional span).
|
|
562
|
+
They are separate metadata fields because M-RoPE media can occupy more cells
|
|
563
|
+
than its positional span. **Do not do arithmetic mixing them** — in particular,
|
|
564
|
+
`cacheTokens - promptTokens` is not the primed prefix size.
|
|
565
|
+
- `promptTokens` on the error is usually absent and, when present, counts one
|
|
566
|
+
guard's view of the slice; on the stats it is always the slice the addon
|
|
567
|
+
processed. Do not compare the two.
|
|
568
|
+
|
|
569
|
+
## Rules for using it
|
|
570
|
+
|
|
571
|
+
1. **Pass the full history.** The SDK computes the delta.
|
|
572
|
+
2. **No client-side token math.** No chars-per-token estimates, no reserved
|
|
573
|
+
response margin, no pre-emptive trimming, no computed budgets. core-v2 has none
|
|
574
|
+
and must keep none.
|
|
575
|
+
3. **React, never predict.** The only supported "will this fit" answer is to send
|
|
576
|
+
it and handle the typed `ContextOverflowError`. Compaction's proactive trigger is
|
|
577
|
+
not an exception: it acts on the SDK's own `cacheTokens` from a round that already
|
|
578
|
+
completed, never on an estimate of the outgoing prompt.
|
|
579
|
+
4. **Use the SDK's real counts** for diagnosis, never estimates.
|
|
580
|
+
5. **Keep the system prompt stable** for a chat — every change costs a cold prime.
|
|
581
|
+
6. **Keep the tool set stable** — `tools` joins `configHash`, so adding,
|
|
582
|
+
dropping, or editing a tool costs a cold prime.
|
|
583
|
+
7. **Only two legitimate overflows** in steady state: the current prompt alone
|
|
584
|
+
exceeds ctx, or a tool result alone exceeds ctx. A third legitimate case: a
|
|
585
|
+
device that never ran this chat receives the whole history (the cache is
|
|
586
|
+
device-local). A fourth: a window below the 4,096 fold floor, which never
|
|
587
|
+
compacts and degrades through the ladder instead. Anything else is a bug.
|
|
588
|
+
|
|
589
|
+
## Debugging
|
|
590
|
+
|
|
591
|
+
Enable harness logs — `QVAC_HARNESS_LOG=1`, or `--log-verbose`. What ships today:
|
|
592
|
+
|
|
593
|
+
```
|
|
594
|
+
round start {"round":0,"turns":16,"tools":1}
|
|
595
|
+
round stats {"round":0,"wallMs":2188,"timeToFirstToken":450,"cacheTokens":835,
|
|
596
|
+
"promptTokens":458,"generatedTokens":84,"backendDevice":"gpu"}
|
|
597
|
+
final {"stopReason":...,"toolCalls":0,"truncated":false}
|
|
598
|
+
add_text: <|im_start|>system ... ← addon output, NOT a harness line (below)
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
- `round start` — `turns` is the history length, `tools` the count passed as the
|
|
602
|
+
separate `tools` argument. Tool definitions never appear in history.
|
|
603
|
+
- `round stats` carries the SDK's real token counts. `cacheTokens` climbing turn
|
|
604
|
+
over turn = warm reuse; dropping back toward the system-prompt size = a re-prime.
|
|
605
|
+
- **Two `add_text` blocks = a cold prime** (system prompt, then the slice); one
|
|
606
|
+
block = warm. But `add_text` is **addon-level** output (it comes from below
|
|
607
|
+
the addon, in llama.cpp), visible only at elevated `QVAC_LLM_VERBOSITY` — the
|
|
608
|
+
addon maps `0→ERROR, 1→WARNING, 2→INFO, 3→DEBUG` (`LoggingMacros.cpp`), so
|
|
609
|
+
use `3` to be sure — and that env feeds `modelConfig.verbosity`, so enabling
|
|
610
|
+
it **re-keys `modelId` and cold-primes every chat** (the trap above). Use it only when verbosity is already on;
|
|
611
|
+
otherwise the safe cold-prime signals are `round stats` (`cacheTokens`
|
|
612
|
+
dropping back toward prompt size) and `xxd` on the `.bin`.
|
|
613
|
+
- A failed turn logs `completion failed {...}`
|
|
614
|
+
([index.ts:1281](../index.ts)) and **no**
|
|
615
|
+
`round stats`, because
|
|
616
|
+
the stats only exist once the SDK settles the run. An absent `cacheTokens` line
|
|
617
|
+
means the turn failed, not that the cache was bypassed.
|
|
618
|
+
- No addon line reports eviction any more: `discarded N tokens after the first
|
|
619
|
+
message`, `[ContextSlider] context is full but cannot discard tokens` and
|
|
620
|
+
`runtimeStats().contextSlides` all left with sliding in addon 0.48. A log
|
|
621
|
+
that shows them is an old build. `normalizeCompletionStats` forwards
|
|
622
|
+
TTFT/TPS/ppTPS/cacheTokens/promptTokens/generatedTokens/avgConcurrentSeq/
|
|
623
|
+
backendDevice, nothing about the KV's shape.
|
|
624
|
+
|
|
625
|
+
Nothing currently reports which context-recovery rung ran. If you need that,
|
|
626
|
+
log per candidate from
|
|
627
|
+
`runRecoveredRoundWith` in `capabilities/completion.ts`: the rung index,
|
|
628
|
+
`droppedTurns` (stage 1), a compaction counter (stage 2), and a roles-only shape
|
|
629
|
+
of the candidate history plus the tool count. Roles only — never message content.
|
|
630
|
+
|
|
631
|
+
**Read the cache file directly** — the header carries the metadata, no
|
|
632
|
+
instrumentation needed:
|
|
633
|
+
|
|
634
|
+
```bash
|
|
635
|
+
xxd -l 32 ~/.qvac/kv-cache/chat-<chatId>/*/*.bin
|
|
636
|
+
# after an accepted diagram repair the live file is one level deeper:
|
|
637
|
+
xxd -l 32 ~/.qvac/kv-cache/chat-<chatId>/r*/*/*.bin
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
```
|
|
641
|
+
7173 6767 0200 0000 0400 0000 7c00 0000 7c00 0000 ...
|
|
642
|
+
^ 'ggsq' ^ ver 2 ^ 4 fields ^ nPast ^ nPast again (retired slot)
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Little-endian `uint32` after the count: `nPast`, `nPast`, `cacheTokens`,
|
|
646
|
+
`cacheTokens` on addon 0.48+ — slots 1 and 3 are the retired first-message
|
|
647
|
+
counters, mirrored from the live values (a file from an older build carries
|
|
648
|
+
`firstMsgTokens` / `firstMsgCacheTokens` there instead). `nPast` climbing turn
|
|
649
|
+
over turn is warm reuse; back to prompt size is a re-prime. An empty
|
|
650
|
+
`chat-<id>/<modelId>/` directory means a rollback unlinked the file — after a
|
|
651
|
+
cancelled turn, a zero-token reply, or a generation that hit the ceiling.
|
|
652
|
+
|
|
653
|
+
To A/B the cache, drop `cacheKey` where `_execute` builds the completion
|
|
654
|
+
request in [assistant.ts](https://github.com/tetherto/qvac-core/blob/main/lib/assistant.ts).
|
|
655
|
+
There is no env kill-switch in core-v2.
|