@qvac/harness 0.2.6 → 0.2.8
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/gh-stack/SKILL.md +24 -24
- package/.claude/skills/gh-stack/references/commands.md +2 -2
- package/.claude/skills/js-house-style/SKILL.md +89 -68
- package/.claude/skills/kvcache/SKILL.md +7 -7
- package/.claude/skills/pr-create/SKILL.md +58 -15
- package/.claude/skills/pr-review/SKILL.md +307 -41
- package/CLAUDE.md +3 -0
- package/README.md +26 -4
- package/dist/harness.bundle +12 -10
- package/dist-lib/index.js +58 -58
- package/dist-lib/lib/audio-transcode-bare.js +2 -2
- package/dist-lib/lib/capabilities/completion.js +30 -30
- package/dist-lib/lib/capabilities/download.js +2 -2
- package/dist-lib/lib/capabilities/speech-text.js +1 -1
- package/dist-lib/lib/capabilities/tool-prompt-log.js +1 -1
- package/dist-lib/lib/capabilities/tool-selection.js +2 -2
- package/dist-lib/lib/capabilities/tool-turns.js +1 -1
- package/dist-lib/lib/capabilities/usage-breakdown.js +1 -1
- package/dist-lib/lib/capabilities/voice.js +3 -3
- package/dist-lib/lib/connect.js +11 -11
- package/dist-lib/lib/context-window.js +1 -1
- package/dist-lib/lib/docx/index.d.ts +5 -0
- package/dist-lib/lib/docx/index.js +49 -0
- package/dist-lib/lib/hardware/capability-probe.js +3 -3
- package/dist-lib/lib/hardware/gpu-probe-addon.js +2 -2
- package/dist-lib/lib/hardware/gpu-probe-entry.js +1 -1
- package/dist-lib/lib/helpers/markup-stream-filter.js +1 -1
- package/dist-lib/lib/helpers/run-outcome.js +1 -1
- package/dist-lib/lib/helpers/spawn-path.d.ts +1 -1
- package/dist-lib/lib/helpers/thinking.js +2 -2
- package/dist-lib/lib/helpers/tool-intent.js +1 -1
- package/dist-lib/lib/helpers/tool-label.js +3 -3
- package/dist-lib/lib/hooks/built-in/arg-repair.d.ts +1 -1
- package/dist-lib/lib/hooks/built-in/arg-repair.js +1 -1
- package/dist-lib/lib/hooks/built-in/egress.d.ts +1 -1
- package/dist-lib/lib/hooks/built-in/egress.js +2 -2
- package/dist-lib/lib/hooks/built-in/index.js +3 -3
- package/dist-lib/lib/hooks/built-in/skill-catalog.d.ts +1 -1
- package/dist-lib/lib/hooks/pipeline.js +4 -4
- package/dist-lib/lib/hooks/registry.js +1 -1
- package/dist-lib/lib/hooks/run.js +1 -1
- package/dist-lib/lib/models/model-catalog.js +4 -4
- package/dist-lib/lib/models/model-delete.js +1 -1
- package/dist-lib/lib/models/model-handle.js +1 -1
- package/dist-lib/lib/models/model-list.js +1 -1
- package/dist-lib/lib/models/model-pool.js +8 -8
- package/dist-lib/lib/models/model-source.js +1 -1
- package/dist-lib/lib/models/music-catalog.d.ts +1 -1
- package/dist-lib/lib/no-redirect-fetch-bare.js +3 -3
- package/dist-lib/lib/no-redirect-fetch-impl.js +1 -1
- package/dist-lib/lib/no-redirect-fetch-node.js +2 -2
- package/dist-lib/lib/resolution-detector.js +1 -1
- package/dist-lib/lib/sdk-loader.js +2 -2
- package/dist-lib/lib/sdk-storage.js +1 -1
- package/dist-lib/lib/serve.js +10 -10
- package/dist-lib/lib/spawn.js +3 -3
- package/dist-lib/lib/staging.js +1 -1
- package/dist-lib/lib/tools/catalog.js +1 -1
- package/dist-lib/lib/tools/create-documents.js +2 -2
- package/dist-lib/lib/tools/edit-documents.js +2 -2
- package/dist-lib/lib/tools/edit-image.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/attachments.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/create-effect.js +1 -1
- package/dist-lib/lib/tools/exec/adapters/python/loader.js +1 -1
- package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +8 -6
- package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +3 -3
- package/dist-lib/lib/tools/exec/adapters/python/run-session.js +5 -5
- package/dist-lib/lib/tools/exec/adapters/python/wheels.js +3 -2
- package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +4 -4
- package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +4 -4
- package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +1 -1
- package/dist-lib/lib/tools/exec/obsidian/cli-process.js +2 -2
- package/dist-lib/lib/tools/exec/obsidian/cli.js +4 -4
- package/dist-lib/lib/tools/exec/obsidian/command.js +1 -1
- package/dist-lib/lib/tools/exec/obsidian/route.js +7 -7
- package/dist-lib/lib/tools/exec/obsidian/vault-create.js +2 -2
- package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +6 -6
- package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +2 -2
- package/dist-lib/lib/tools/exec/obsidian/vault-read.js +1 -1
- package/dist-lib/lib/tools/exec/obsidian/vault-write.js +2 -2
- package/dist-lib/lib/tools/exec/runner.js +1 -1
- package/dist-lib/lib/tools/exec.js +13 -13
- package/dist-lib/lib/tools/file-glob.js +3 -3
- package/dist-lib/lib/tools/fs/local.js +1 -1
- package/dist-lib/lib/tools/fs/remote.js +1 -1
- package/dist-lib/lib/tools/fs/walk.js +2 -2
- package/dist-lib/lib/tools/generate-image.js +4 -4
- package/dist-lib/lib/tools/generate-music.js +6 -6
- package/dist-lib/lib/tools/grep.js +6 -6
- package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
- package/dist-lib/lib/tools/http-request.js +9 -9
- package/dist-lib/lib/tools/mcp/utils.js +1 -1
- package/dist-lib/lib/tools/mcp-call.js +8 -8
- package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
- package/dist-lib/lib/tools/operations/builders.js +1 -1
- package/dist-lib/lib/tools/operations/compile.js +1 -1
- package/dist-lib/lib/tools/operations/manifest.d.ts +16 -16
- package/dist-lib/lib/tools/operations/manifest.js +1 -1
- package/dist-lib/lib/tools/outbound-guard.js +1 -1
- package/dist-lib/lib/tools/rag-search.js +1 -1
- package/dist-lib/lib/tools/read-files.js +3 -3
- package/dist-lib/lib/tools/skill.js +8 -8
- package/dist-lib/lib/tools/skills/auth-resolver.js +2 -2
- package/dist-lib/lib/tools/skills/bin-check.js +1 -1
- package/dist-lib/lib/tools/skills/bundled-dir.js +2 -2
- package/dist-lib/lib/tools/skills/catalog.js +10 -10
- package/dist-lib/lib/tools/skills/cli-schema-check.js +1 -1
- package/dist-lib/lib/tools/skills/cli-schema-load.js +1 -1
- package/dist-lib/lib/tools/skills/cli-schema.js +4 -4
- package/dist-lib/lib/tools/skills/credential-check.js +3 -3
- package/dist-lib/lib/tools/skills/credential-grants.js +1 -1
- package/dist-lib/lib/tools/skills/credential-health.js +2 -2
- package/dist-lib/lib/tools/skills/frontmatter-parser.js +1 -1
- package/dist-lib/lib/tools/skills/index.js +5 -5
- package/dist-lib/lib/tools/skills/mention.js +1 -1
- package/dist-lib/lib/tools/skills/overlay-crud.js +1 -1
- package/dist-lib/lib/tools/skills/setup-routes.js +1 -1
- package/dist-lib/lib/tools/skills/skill-settings.js +1 -1
- package/dist-lib/lib/tools/skills/skill-state-constants.d.ts +8 -8
- package/dist-lib/lib/tools/skills/skill-state.js +9 -9
- package/dist-lib/lib/tools/toolsets.d.ts +1 -1
- package/dist-lib/lib/tools/url-guard.js +2 -2
- package/dist-lib/lib/tools/weather-lookup/place.d.ts +11 -0
- package/dist-lib/lib/tools/weather-lookup/place.js +152 -0
- package/dist-lib/lib/tools/weather-lookup/report.d.ts +37 -0
- package/dist-lib/lib/tools/weather-lookup/report.js +56 -0
- package/dist-lib/lib/tools/weather-lookup.d.ts +8 -0
- package/dist-lib/lib/tools/weather-lookup.js +110 -0
- package/dist-lib/lib/tools/web-fetch.js +10 -10
- package/dist-lib/lib/tools/web-search.js +2 -2
- package/dist-lib/lib/transport.js +1 -1
- package/dist-lib/lib/validating-lookup.js +1 -1
- package/dist-lib/lib/web-fetch/embedded-state.js +1 -1
- package/dist-lib/lib/web-fetch/extract.js +1 -1
- package/dist-lib/lib/web-fetch/images.js +1 -1
- package/dist-lib/lib/web-fetch/video.js +1 -1
- package/dist-lib/lib/web-search/engine-registry.js +7 -7
- package/dist-lib/lib/web-search/engines/aol.js +3 -3
- package/dist-lib/lib/web-search/engines/bing.js +4 -4
- package/dist-lib/lib/web-search/engines/brave.js +2 -2
- package/dist-lib/lib/web-search/engines/duckduckgo.js +3 -3
- package/dist-lib/lib/web-search/engines/google.js +4 -4
- package/dist-lib/lib/web-search/engines/wikipedia.js +2 -2
- package/dist-lib/lib/web-search/engines/yahoo.js +3 -3
- package/dist-lib/lib/web-search/fetch-html.js +1 -1
- package/dist-lib/lib/web-search/merge-results.js +1 -1
- package/dist-lib/lib/web-search/orchestrator.js +3 -3
- package/dist-lib/schema/baseline.js +1 -1
- package/dist-lib/schema/build.js +3 -3
- package/dist-lib/schema/release-tags.d.ts +2 -1
- package/dist-lib/schema/release-tags.js +9 -7
- 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/2026-09-10-skill-wording-is-measured-offline.md +63 -0
- package/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md +70 -0
- package/docs/decisions/INDEX.md +40 -0
- package/docs/decisions/TEMPLATE.md +32 -0
- package/package.json +21 -4
- package/worker/harness-sidecar.mjs +2 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ADR-2026-06-02-kvcache-tool-turns-reenabled
|
|
3
|
+
status: superseded
|
|
4
|
+
introduced_by: PR-QVAC-19770
|
|
5
|
+
supersedes: [ADR-2026-05-29-kvcache-tool-turns]
|
|
6
|
+
superseded_by: [ADR-2026-08-20-kvcache-static-placement]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# KV-cache session is re-enabled for tool-using completions
|
|
10
|
+
|
|
11
|
+
> **Superseded** by
|
|
12
|
+
> [ADR-2026-08-20-kvcache-static-placement](./2026-08-20-kvcache-static-placement.md):
|
|
13
|
+
> the half of this record that still holds is that `kvCache: 'chat-<id>'` rides
|
|
14
|
+
> every completion. The dynamic-mode half was built on a false premise — the
|
|
15
|
+
> addon's compact-tools anchoring never ran on any shipped model (its arch gate
|
|
16
|
+
> is exact `qwen3`; ours are `qwen35`/`qwen36`), so the coherence this record
|
|
17
|
+
> credits to dynamic mode was never exercised, and the "static never surfaced
|
|
18
|
+
> the tool-call format" claim did not survive re-testing. core-v2 loads static
|
|
19
|
+
> with no sliding window since 2026-08-20; mechanics in
|
|
20
|
+
> `docs/kvcache-reference.md`. The `QVAC_PROMPT_CACHE` kill-switch and
|
|
21
|
+
> `deleteChatPromptCache` described below were core-v1 machinery and do not
|
|
22
|
+
> exist in the current runtime.
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
The `@qvac/sdk` session prompt cache (`completion({ kvCache: "chat-<id>" })`) is
|
|
27
|
+
passed for **all** completions with a chatId — tool-using and tool-free alike —
|
|
28
|
+
gated only by the `QVAC_PROMPT_CACHE` kill-switch. This reverses
|
|
29
|
+
ADR-2026-05-29, which disabled the cache whenever tools were present.
|
|
30
|
+
|
|
31
|
+
## Context
|
|
32
|
+
|
|
33
|
+
The prior decision disabled tool-turn caching because the SDK's DYNAMIC cache
|
|
34
|
+
path broke tool-call parsing: it ships a role-based history fragment and trusts
|
|
35
|
+
the addon's tool-chain anchoring, which our reconstructed history did not
|
|
36
|
+
satisfy → `<tool_call>` markup leaked as text. Two things changed:
|
|
37
|
+
|
|
38
|
+
- Models loaded with `modelConfig.toolsMode: "dynamic"` (QVAC-19660). In
|
|
39
|
+
dynamic mode the addon anchors tools per-turn (compact-tools), excludes the
|
|
40
|
+
tool set from the cache config hash, and the fragment slicing keeps
|
|
41
|
+
multi-round tool turns coherent — including across a model evict/reload.
|
|
42
|
+
**No longer true as of `@qvac/sdk` 0.18 (QVAC-23936):** the key was removed
|
|
43
|
+
and the parser rejects it, so static is the only placement. Dynamic was in
|
|
44
|
+
any case a silent no-op on every arch we ship — the addon gates
|
|
45
|
+
`tools_compact` on exact-arch `qwen3` while our catalog is `qwen35`/`qwen36` —
|
|
46
|
+
and the SDK still sliced as-if-dynamic, which made the cache the chat's
|
|
47
|
+
_memory_ rather than an accelerator: after any cache reset it sent only the
|
|
48
|
+
last exchange. Under static placement the tool block sits in the addon's
|
|
49
|
+
protected prefix instead, and 0.18 keeps it out of the primed prefix
|
|
50
|
+
(QVAC-23251) so the qwen3.5 template no longer raises 'No user query found in
|
|
51
|
+
messages.'
|
|
52
|
+
- An SDK e2e guard (`kv-cache-tools-dynamic-reuse`, plus a qwen35-dialect
|
|
53
|
+
variant) verifies a custom-key multi-round tool chain reuses the on-disk cache
|
|
54
|
+
(`cacheTokens > 0`) and keeps tool calls parseable across an evict/reload.
|
|
55
|
+
|
|
56
|
+
With the DYNAMIC path proven coherent across reload, the heavyweight
|
|
57
|
+
"reconstruction invalidation / byte-identical history" work the prior ADR
|
|
58
|
+
deferred is unnecessary; divergence is handled with targeted invalidation.
|
|
59
|
+
|
|
60
|
+
## Consequences
|
|
61
|
+
|
|
62
|
+
- **Do not pass `toolsMode` at model load.** SDK 0.18 removed the key and its
|
|
63
|
+
parser rejects an unrecognised one, so passing it fails the load outright and
|
|
64
|
+
the app parks on the splash. Static placement is the default and the only mode.
|
|
65
|
+
- Dropping a key from `modelConfig` re-keys `modelId`, which is identity-by-config,
|
|
66
|
+
so expect one cold prime per chat on the first run after such a change.
|
|
67
|
+
- Tool turns reuse the session KV cache: lower TTFT/prefill on multi-round tool
|
|
68
|
+
turns and follow-ups (manually: round-2 TTFT ~1.1s → ~0.3s; cacheTokens grows
|
|
69
|
+
monotonically across a conversation).
|
|
70
|
+
- `QVAC_PROMPT_CACHE` (default on; `0`/`false`/`off`) disables all
|
|
71
|
+
prompt caching as a kill-switch.
|
|
72
|
+
- The per-chat cache is explicitly invalidated on chat delete and agent delete
|
|
73
|
+
(which cascades to its chats) via `deleteChatPromptCache`, plus a defensive
|
|
74
|
+
drop on retry (see below). Edit / branch / truncate flows do not exist yet and
|
|
75
|
+
need no hook.
|
|
76
|
+
- **Why stop / failed / new-message-after-failure need no invalidation.** The
|
|
77
|
+
cache only ever holds _successful_ turns: the SDK rolls back uncommitted turns
|
|
78
|
+
on abort (the custom-key path commits only when not aborted and tokens were
|
|
79
|
+
produced), and failed / canceled / not_delivered replies are excluded from
|
|
80
|
+
model-visible history (`isModelVisibleMessage`, `qvac-app → core/src/services/common/history.ts`).
|
|
81
|
+
So a stopped or failed turn leaves neither a committed cache entry nor a
|
|
82
|
+
visible history row — the reconstructed history and the cached prefix stay
|
|
83
|
+
aligned, and the next turn (new message or retry) does not slice against a
|
|
84
|
+
stale prefix. The SDK's own `kv-cache-cancel-then-new-prompt` e2e covers this
|
|
85
|
+
cancel-then-continue coherence. The **retry** `deleteChatPromptCache` is
|
|
86
|
+
therefore belt-and-suspenders, not load-bearing: it costs one re-prime on a
|
|
87
|
+
rare, user-initiated path and removes any dependence on the rollback timing.
|
|
88
|
+
- **Mobile scope.** Mobile now runs the same assistant and harness completion path
|
|
89
|
+
inside its supervised Bare worklets, so the per-chat cache key reaches the SDK
|
|
90
|
+
without the retired worker-to-host SDK bridge.
|
|
91
|
+
- `chatPromptCacheKey` is the single source of truth for the `chat-<id>` key
|
|
92
|
+
shared by the completion path and the invalidation callers.
|
|
93
|
+
- Known SDK limitation: on a multimodal (M-RoPE, `n_pos_per_embd > 1`) model such
|
|
94
|
+
as Qwen3.5, the SDK's KV sliding-window position shift (`seq_add`/`seq_div` via
|
|
95
|
+
`trySlidePrefill`) GGML_ASSERT-crashes the worker when the context fills. A
|
|
96
|
+
standalone SDK repro confirmed this is **independent of `toolsMode` (static and
|
|
97
|
+
dynamic both crash) and of whether tools are passed**, and is not caused by the
|
|
98
|
+
prompt cache — it is the sliding window itself, unsupported for multi-pos
|
|
99
|
+
models. Interim mitigation (separate from the kill-switch): we temporarily
|
|
100
|
+
stopped setting `n_discarded` at model load so on overflow the SDK took the
|
|
101
|
+
catchable `CONTEXT_OVERFLOW` path instead of the crashing slide.
|
|
102
|
+
**Resolved (2026-06-23):** `@qvac/sdk` 0.13.5 (llm-llamacpp 0.24.0) ships
|
|
103
|
+
M-RoPE/iM-RoPE sliding-context support, so `n_discarded` is set again in
|
|
104
|
+
`@qvac/sdk → model/model-load-config.ts` and the workbench no longer trims history. See
|
|
105
|
+
[`2026-06-23-append-only-history.md`](./2026-06-23-append-only-history.md).
|
|
106
|
+
- Regression guards updated: the contract test now asserts `kvCache` IS sent
|
|
107
|
+
with tools; the spec-fake models dynamic mode (healthy on a tools cache hit);
|
|
108
|
+
invalidation hooks are covered.
|
|
109
|
+
- **The in-repo suite cannot prove real dynamic-mode coherence.** The spec-fake
|
|
110
|
+
(`@qvac/sdk → __fixtures__/fake-completion-sdk.ts`) is parameterized to treat dynamic mode
|
|
111
|
+
as healthy, so the in-repo tests guard against regressing the model load back
|
|
112
|
+
to static mode and against dropping the `kvCache` key — but they do not prove
|
|
113
|
+
the SDK's real DYNAMIC path stays coherent against our reconstructed history.
|
|
114
|
+
That proof lives in the out-of-repo SDK e2e (`kv-cache-tools-dynamic-reuse`
|
|
115
|
+
and its qwen35 variant), which this repo's CI cannot run.
|
|
116
|
+
|
|
117
|
+
## When Agents Should Read This
|
|
118
|
+
|
|
119
|
+
Read this before changing how `kvCache` is passed to `sdk.completion`,
|
|
120
|
+
`toolsMode` at model load, the agentic loop's history construction, transcript
|
|
121
|
+
reconstruction, or the cache invalidation hooks —
|
|
122
|
+
`qvac-app → core/src/services/message/agentic-completion.ts`,
|
|
123
|
+
`qvac-app → core/src/services/model/service.ts`,
|
|
124
|
+
`qvac-app → core/src/services/execution/service.ts`,
|
|
125
|
+
`qvac-app → core/src/services/api/domain-apis.ts`,
|
|
126
|
+
`@qvac/core → lib/assistant.ts`, and
|
|
127
|
+
`lib/capabilities/completion.ts`.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ADR-2026-06-23-android-cpu-llm-defaults
|
|
3
|
+
status: active
|
|
4
|
+
introduced_by: PR-691
|
|
5
|
+
supersedes: []
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Android LLM defaults to CPU except Tensor-era Pixels
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Android LLM inference defaults to CPU with a `q4_0` KV cache, except on
|
|
13
|
+
Tensor-era Google Pixel devices before Pixel 10 where LLM inference defaults
|
|
14
|
+
back to GPU. Pixel 10 and newer remain on CPU.
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
Android GPU inference runs on Vulkan, which is unreliable across the fleet:
|
|
19
|
+
Adreno on Snapdragon devices, including many Samsung variants, aborts under the
|
|
20
|
+
LLM workload, and older devices ship buggy drivers. CPU is the only
|
|
21
|
+
consistently stable target.
|
|
22
|
+
|
|
23
|
+
The earlier Samsung and Pixel 10 CPU special-casing plus stripping the KV-cache
|
|
24
|
+
quantization fields at the host boundary existed only to dodge the Vulkan abort.
|
|
25
|
+
With all inference on CPU that abort path is gone, so a quantized `q4_0` cache
|
|
26
|
+
is safe and lowers memory use.
|
|
27
|
+
|
|
28
|
+
Pixel 9 profiling later showed that long chat/tool-shaped prompts can collapse
|
|
29
|
+
decode throughput on the SDK CPU path while the GPU path keeps decode TPS
|
|
30
|
+
healthy. Pixel 10 had prior GPU stability issues, so the exception is limited
|
|
31
|
+
to Tensor-era Pixel devices before Pixel 10, including Pixel Tablet and the
|
|
32
|
+
first-generation Pixel Fold. Original Pixel phones are not included in this
|
|
33
|
+
exception.
|
|
34
|
+
|
|
35
|
+
Core v2 device profiling (2026-07) added two KV-cache facts for the GPU
|
|
36
|
+
exception: a quantized V cache aborts the Adreno backend outright, and a
|
|
37
|
+
quantized K cache forces per-token dequantization onto a slow flash-attention
|
|
38
|
+
path on Mali (Pixel 8 decode dropped from ~10 tps to ~3 tps). The GPU branch
|
|
39
|
+
therefore sends no KV-cache override at all; the SDK default f16/f16 is both
|
|
40
|
+
the stable and the fast profile, matching shipped 0.12. The harness
|
|
41
|
+
additionally clamps cache-type-v to f16 on Android GPU loads as a backstop.
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
- Embeddings follow the same split, and only on Android: Android devices and
|
|
46
|
+
every simulator pin `embedDevice: "cpu"`, while iOS devices keep GPU
|
|
47
|
+
embeddings. That matches the retired Core v1 policy and reflects the failure modes:
|
|
48
|
+
Adreno/Vulkan aborts outright, whereas a Metal device-lost fault self-heals
|
|
49
|
+
through the harness escalation in `lib/models/sdk-faults.ts`
|
|
50
|
+
(reload once, fall back to CPU on a repeat).
|
|
51
|
+
- Do not reintroduce host-boundary KV-cache field stripping; it only made sense
|
|
52
|
+
for the GPU/Vulkan path.
|
|
53
|
+
- This trades GPU acceleration on most Android devices for reliability and
|
|
54
|
+
lower memory.
|
|
55
|
+
- Tensor-era Pixel devices before Pixel 10 keep GPU acceleration for LLM
|
|
56
|
+
completions.
|
|
57
|
+
- Pixel 10 and newer stay on the Android CPU/q4_0 policy.
|
|
58
|
+
- Revisit if the SDK gains a capability probe that can distinguish Vulkan
|
|
59
|
+
drivers/devices that are safe under load, allowing a selective GPU re-enable.
|
|
60
|
+
|
|
61
|
+
## Amendment 2026-08-03: the abort attribution is unverified
|
|
62
|
+
|
|
63
|
+
The Adreno aborts above are attributed to Vulkan, but the app never supplied
|
|
64
|
+
`openclCacheDir` on any load. `@qvac/llm-llamacpp` documents Adreno 700+ as an
|
|
65
|
+
**OpenCL** target, and the OpenCL backend `ggml_abort()`s during init when it
|
|
66
|
+
cannot write its kernel cache, the same symptom this ADR blames on Vulkan. The
|
|
67
|
+
backend that actually ran was never recorded, so which of the two caused the
|
|
68
|
+
aborts is still open.
|
|
69
|
+
|
|
70
|
+
Three things changed to make it answerable rather than re-litigated:
|
|
71
|
+
|
|
72
|
+
- `configureSdkStorage` now exports a writable kernel-cache directory, and the
|
|
73
|
+
harness passes `openclCacheDir` on Android LLM, embedding and transcription
|
|
74
|
+
loads (`lib/models/opencl-cache.ts`).
|
|
75
|
+
- The KV clamp covers **K as well as V** on Android GPU loads
|
|
76
|
+
(`adrenoSafeKvCache`). Adreno OpenCL throws on any K type other than
|
|
77
|
+
`f16`/`f32`/`bf16`, and this ADR's own Mali finding points the same way; only V
|
|
78
|
+
was clamped before, so an agent row pinning `kvCacheK` reached the load
|
|
79
|
+
unclamped.
|
|
80
|
+
- `EXPO_PUBLIC_QVAC_ANDROID_LLM_DEVICE` and
|
|
81
|
+
`EXPO_PUBLIC_QVAC_ANDROID_EMBED_DEVICE` (`"cpu" | "gpu"`) override the Android
|
|
82
|
+
branches per build, so a device can be retested without a code change. The
|
|
83
|
+
simulator branch ignores them.
|
|
84
|
+
|
|
85
|
+
The defaults in this ADR are unchanged and remain in force. Before flipping any
|
|
86
|
+
of them, record on real hardware the `backendDevice` value from
|
|
87
|
+
`hlog('round stats', …)` (the SDK reports `cpu | vulkan | metal | opencl`) plus
|
|
88
|
+
cold and warm `hlog('llm loaded', { loadMs })`. `opencl` means the kernel cache
|
|
89
|
+
is load-bearing and this ADR's attribution needs correcting; `vulkan` means the
|
|
90
|
+
cache dir is inert on this fleet and the CPU pin stands on its original
|
|
91
|
+
reasoning.
|
|
92
|
+
|
|
93
|
+
## Amendment 2026-09-04: transcription follows the split too
|
|
94
|
+
|
|
95
|
+
The Consequences above extended the split to embeddings and stopped there, so
|
|
96
|
+
the transcription pool loaded parakeet with a hardcoded `useGPU: true` on every
|
|
97
|
+
device — including the Pixel 10 whose GPU this ADR pins to CPU for the LLM. On
|
|
98
|
+
that hardware the model loads, runs for a time proportional to the clip, and
|
|
99
|
+
returns an empty transcript, so the app reports "contained no detectable
|
|
100
|
+
speech" about a file full of speech. Nothing throws, so the
|
|
101
|
+
`voice gpu load refused, retrying on cpu` fallback added for QVAC-24132 never
|
|
102
|
+
fires: it covers a load that fails, not one that succeeds and produces nothing.
|
|
103
|
+
|
|
104
|
+
Transcription now takes the same GPU verdict as the LLM, carried on the host
|
|
105
|
+
boundary as `transcriptionDevice` (`"cpu" | "gpu"`) alongside `llmDevice` and
|
|
106
|
+
`embedDevice`, with `EXPO_PUBLIC_QVAC_ANDROID_TRANSCRIPTION_DEVICE` and
|
|
107
|
+
`EXPO_PUBLIC_QVAC_IOS_TRANSCRIPTION_DEVICE` to retest a device per build. An
|
|
108
|
+
absent value still means GPU, so desktop is unaffected. Parakeet allocates its
|
|
109
|
+
weights in one `ggml_backend_alloc_ctx_tensors` call with no partial-offload
|
|
110
|
+
knob, so a GPU this ADR does not trust with the LLM cannot be trusted with it
|
|
111
|
+
either.
|
|
112
|
+
|
|
113
|
+
Still open: the voice pools have no equivalent of the embedding fault
|
|
114
|
+
escalation in `lib/models/sdk-faults.ts`, so an empty
|
|
115
|
+
transcript from a GPU-loaded model is reported as fact rather than retried on
|
|
116
|
+
CPU. Until that exists, a mis-set device is silent rather than self-healing.
|
|
117
|
+
|
|
118
|
+
## When Agents Should Read This
|
|
119
|
+
|
|
120
|
+
Read when changing Android device selection or KV-cache configuration for any
|
|
121
|
+
inference category — LLM, embeddings or transcription — in
|
|
122
|
+
`qvac-app → mobile/lib/android-llm-device.ts`, `qvac-app → mobile/lib/core-v2-harness-config.ts` or
|
|
123
|
+
the pool constructors in `index.ts`, or re-evaluating the
|
|
124
|
+
Android Vulkan/CPU and KV-cache Known Risks. Read the 2026-08-03 amendment
|
|
125
|
+
before treating the Vulkan attribution as settled, and the 2026-09-04 amendment
|
|
126
|
+
before adding a category that loads a model on the GPU.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ADR-2026-07-29-assistant-owned-storage-layout
|
|
3
|
+
status: active
|
|
4
|
+
introduced_by: PR-000
|
|
5
|
+
supersedes: []
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Assistant-owned storage separates shared models from private KV
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
The assistant owns the Desktop and Mobile storage layout. Hosts pass a release
|
|
13
|
+
environment plus optional root/model overrides; they do not construct assistant
|
|
14
|
+
or SDK subpaths.
|
|
15
|
+
|
|
16
|
+
Models stay at the SDK's canonical shared location on every platform:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
~/.qvac/models/ SDK-wide model and asset cache
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
(Snap uses `$SNAP_USER_COMMON` as the home base, i.e.
|
|
23
|
+
`$SNAP_USER_COMMON/.qvac/models`.) This deliberately matches the `@qvac/sdk`
|
|
24
|
+
default (`getDefaultCacheDir → $HOME/.qvac/models`) so a standalone SDK, a CLI, or
|
|
25
|
+
another QVAC product on the same machine reuses one downloaded model cache rather
|
|
26
|
+
than each re-downloading multi-GB assets. Because of that sharing requirement
|
|
27
|
+
models are **not** moved under `XDG_DATA_HOME` — the host may still relocate them
|
|
28
|
+
explicitly via `QVAC_MODEL_CACHE_PATH` / the `models` override.
|
|
29
|
+
|
|
30
|
+
The assistant **image cache** resolves via `bare-storage.persistent()` —
|
|
31
|
+
`qvac/image-cache` under the OS data tier on every platform (`$XDG_DATA_HOME` on
|
|
32
|
+
Linux, `~/Library/Application Support` on macOS, `%APPDATA%` on Windows; Flatpak
|
|
33
|
+
`~/.var/app/<id>/data`). It stays in the _data_ tier (not `ephemeral()`/cache)
|
|
34
|
+
because the assistant sweeps it itself (`imageCacheMaxBytes`/`imageCacheMaxAgeMs`
|
|
35
|
+
→ `sweepImageCache`), so it does not rely on OS cache eviction.
|
|
36
|
+
|
|
37
|
+
Private state is rooted at the platform persistent-data directory returned by
|
|
38
|
+
`bare-storage` (or `SNAP_USER_COMMON` for Snap):
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
qvac/{prod|staging|dev}/
|
|
42
|
+
├── assistant/ private application state
|
|
43
|
+
└── sdk-home/ private SDK runtime home, registry, and KV cache
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The assistant writes an SDK JSON config with `cacheDirectory` pointing at the
|
|
47
|
+
canonical models directory and uses the SDK's supported home override to assign
|
|
48
|
+
each app/channel a private `sdk-home`. The SDK continues to derive its own
|
|
49
|
+
KV-cache subpath; Workbench does not duplicate that SDK implementation detail.
|
|
50
|
+
|
|
51
|
+
On Mobile, Delete All Data asks the live assistant harness to call the SDK's
|
|
52
|
+
`deleteCache({ all: true })` operation before stopping Core, then removes only
|
|
53
|
+
the `assistant` directory. A failed or unsupported KV-cache operation aborts the
|
|
54
|
+
reset so Workbench does not report permanent deletion while conversation-derived
|
|
55
|
+
cache remains. The host never deletes `sdk-home` directly, preserving SDK
|
|
56
|
+
registry metadata alongside the shared model assets.
|
|
57
|
+
|
|
58
|
+
The channel that home override rides on is per-runtime, not per-process. Off
|
|
59
|
+
bare-kit it is `env[SNAP_USER_COMMON]`, which is also the key `resolveStorage`
|
|
60
|
+
reads as the platform root; on bare-kit it is `Bare.argv[0]`, which `@qvac/sdk`
|
|
61
|
+
prefers over `HOME_DIR` and resolves lazily. Bare-kit worklets share one OS
|
|
62
|
+
`environ`, so a host that runs two of them — mobile runs the assistant and the
|
|
63
|
+
harness side by side — would have each `configureSdkStorage` redefine the root
|
|
64
|
+
the other's `resolveStorage` is about to read. Argv is copied into each
|
|
65
|
+
runtime, so the write cannot leave the worklet that made it.
|
|
66
|
+
|
|
67
|
+
Desktop `--storage`, `--tmp`, and `QVAC_STORAGE_PATH` replace only the private
|
|
68
|
+
root. `QVAC_MODEL_CACHE_PATH` is a separate override for hermetic tests and
|
|
69
|
+
other explicit host requirements. Mobile applies the same relative layout, but
|
|
70
|
+
OS app-ID sandboxes naturally prevent sharing across Development, Staging, and
|
|
71
|
+
Production installations.
|
|
72
|
+
|
|
73
|
+
## Consequences
|
|
74
|
+
|
|
75
|
+
- Desktop apps and release channels reuse downloaded model assets without
|
|
76
|
+
sharing conversations, meshes, runtime locks, or KV caches.
|
|
77
|
+
- Mobile app reset clears every SDK KV cache through the harness and deletes
|
|
78
|
+
private assistant state while leaving shared models and SDK registry metadata
|
|
79
|
+
intact.
|
|
80
|
+
- Existing `~/.qvac/models` downloads continue to be used on every platform —
|
|
81
|
+
models are unchanged and stay aligned with the SDK default. The assistant
|
|
82
|
+
**image cache** moved from `~/.qvac-assistant/image-cache` to
|
|
83
|
+
`bare-storage.persistent()` (`qvac/image-cache`, data tier) — a greenfield
|
|
84
|
+
change with no migration of prior cache data. Legacy global KV and app-data
|
|
85
|
+
directories are not migrated or deleted.
|
|
86
|
+
- SDK cache layout changes remain owned by the SDK: Core supplies the supported
|
|
87
|
+
model-cache and home overrides and invokes SDK cache deletion without
|
|
88
|
+
reproducing the KV subpath.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ADR-2026-08-04-sidecar-orphan-reaping
|
|
3
|
+
status: active
|
|
4
|
+
introduced_by: PR-1382
|
|
5
|
+
supersedes: []
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The host reaps the sidecar's process group; the harness cannot reap itself
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
When the assistant sidecar exits, the desktop host kills the sidecar's whole process group.
|
|
13
|
+
The sidecar is spawned detached (`qvac-app → desktop/electron/assistant.ts`), so it leads its own group
|
|
14
|
+
and every harness it spawns stays in that group; killing the group on the exit edge reaps the
|
|
15
|
+
strays and can reach nothing else.
|
|
16
|
+
|
|
17
|
+
This is POSIX-only, and so is the bug. On Windows the kernel already ties the tree together:
|
|
18
|
+
libuv assigns every non-detached child to a Job Object created with `KILL_ON_JOB_CLOSE`
|
|
19
|
+
("so windows will kill it when the parent process dies" — libuv's own comment), and
|
|
20
|
+
`bare-subprocess` is a thin `uv_spawn` binding that sets `UV_PROCESS_DETACHED` only when asked.
|
|
21
|
+
The sidecar and the harness are both spawned non-detached there, so killing the sidecar kills
|
|
22
|
+
the harness at kernel level, mid-inference included. Verified empirically on a real Windows
|
|
23
|
+
machine: sidecar killed mid-generation, both bare processes died, nothing lingered.
|
|
24
|
+
|
|
25
|
+
The harness does not try to detect its own orphaning.
|
|
26
|
+
|
|
27
|
+
## Context
|
|
28
|
+
|
|
29
|
+
The harness holds gigabytes of loaded model weights. Stow's stop signal is pipe EOF, and a
|
|
30
|
+
sidecar killed outright (SIGKILL, OOM) never runs the ordered teardown that bounds its child —
|
|
31
|
+
so on POSIX the harness reparents to init and holds that memory until the machine reboots.
|
|
32
|
+
|
|
33
|
+
A manual `kill -9` is only the cleanest way to reproduce it. The same state follows from an
|
|
34
|
+
`abort()` or segfault in one of the sidecar's native addons (SQLite, the vector extension,
|
|
35
|
+
ffmpeg image normalization), from the kernel OOM killer picking the sidecar instead of the
|
|
36
|
+
harness, or from a developer killing a hung app.
|
|
37
|
+
|
|
38
|
+
## Alternatives Considered
|
|
39
|
+
|
|
40
|
+
An in-process watchdog in the harness (poll `os.ppid()`, exit on reparent) was implemented and
|
|
41
|
+
reverted. The shipped bundle did contain the call, unref'd intervals do fire under bare, and
|
|
42
|
+
`os.ppid()` is a live `getppid()` that reflects reparenting — yet a harness orphaned for minutes
|
|
43
|
+
never exited. A harness parked in a native SDK call is not running JS at all, so no _JS-level_
|
|
44
|
+
mechanism can survive it, whatever signal it watches.
|
|
45
|
+
|
|
46
|
+
A native parent-death watcher would survive it, since it does not need the JS thread:
|
|
47
|
+
`WaitForSingleObject` on the parent handle, `PR_SET_PDEATHSIG` on Linux, kqueue `NOTE_EXIT` on
|
|
48
|
+
macOS. All require native work in bare, so the host-side reap was preferred.
|
|
49
|
+
|
|
50
|
+
## Consequences
|
|
51
|
+
|
|
52
|
+
- The host is the only place that can bound the harness on abrupt sidecar death. The reap must
|
|
53
|
+
stay wired for every sidecar spawned, whether or not a renderer ever adopted it.
|
|
54
|
+
- The `detached: process.platform !== "win32"` split is load-bearing in both directions. POSIX
|
|
55
|
+
needs the detach or the group kill has nothing to aim at; Windows needs the non-detach or
|
|
56
|
+
libuv skips the job assignment and the kernel tie is lost. Setting `detached: true` on Windows
|
|
57
|
+
would create there the very bug this reap fixes on POSIX.
|
|
58
|
+
- Keep the harness in the sidecar's group — never spawn it detached, on any platform. On POSIX
|
|
59
|
+
a detached harness leaves the sidecar's group and silently escapes this reap (no error, no
|
|
60
|
+
failing test unless the real-process test in
|
|
61
|
+
`qvac-app → desktop/test/electron/assistant-sidecar-orphan.test.ts` is kept); on Windows it leaves the
|
|
62
|
+
sidecar's job and escapes the kernel cascade the same way.
|
|
63
|
+
- Prefer the group over a pid on POSIX. The harness's own children (exec and shell tools) are in
|
|
64
|
+
the group too; killing the harness pid alone leaves them orphaned.
|
|
65
|
+
- The win32 branch of `forceKillAssistantSidecarTree` (`taskkill /T` on the sidecar) matters for
|
|
66
|
+
the clean-stop escalation, where the sidecar is alive but will not unwind; on the exit edge it
|
|
67
|
+
is a post-mortem no-op — the job object has already done the work.
|
|
68
|
+
- Not solved on POSIX: the host dying abruptly while the sidecar is itself blocked in a native
|
|
69
|
+
call. Then the sidecar cannot act on its own pipe EOF and nothing alive can reap either
|
|
70
|
+
process. A sweep for stray harnesses at startup is the remedy if that shows up in practice.
|
|
71
|
+
(Windows is covered even here: the host's death closes its job handle and the cascade takes
|
|
72
|
+
the whole tree.)
|
|
73
|
+
- `killStowChildren()` (`bare-supervisor/stow`) still covers a clean host exit; this covers the
|
|
74
|
+
abrupt sidecar death it cannot see.
|
|
75
|
+
|
|
76
|
+
## When Agents Should Read This
|
|
77
|
+
|
|
78
|
+
Read this when changing the paths, workflows, runtime boundaries, or platform behavior listed
|
|
79
|
+
for this ADR in `docs/decisions/INDEX.md`.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ADR-2026-08-11-pyodide-worker-thread
|
|
3
|
+
status: active
|
|
4
|
+
introduced_by: PR-1524
|
|
5
|
+
supersedes: []
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Pyodide runs in a worker thread with a shared interrupt buffer
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
The harness executes python (Pyodide, CPython on WebAssembly) in a dedicated
|
|
13
|
+
worker thread — `Bare.Thread` + `bare-channel` on Bare, `worker_threads` on
|
|
14
|
+
Node — never on the harness thread. Cancellation goes through a 1-byte
|
|
15
|
+
`SharedArrayBuffer` registered with `pyodide.setInterruptBuffer()`: the host
|
|
16
|
+
writes SIGINT, CPython raises `KeyboardInterrupt` at the next bytecode
|
|
17
|
+
boundary, and the interpreter survives for the next run. A run that ignores
|
|
18
|
+
the interrupt past a grace window gets the worker killed and respawned.
|
|
19
|
+
|
|
20
|
+
## Context
|
|
21
|
+
|
|
22
|
+
`runPython` is synchronous inside Pyodide, so on the harness thread a busy
|
|
23
|
+
script froze every tool and chat turn, and timeout/abort could only settle
|
|
24
|
+
before or after a run. An interrupt can only be delivered from another thread
|
|
25
|
+
(the blocked thread cannot run JS), so shared memory plus a second thread is
|
|
26
|
+
the minimal correct shape. Two Bare constraints shaped the implementation:
|
|
27
|
+
|
|
28
|
+
- `Bare.Thread.terminate()` is honored at a safepoint only. It cannot stop a
|
|
29
|
+
busy-spinning thread, and `join()` on one blocks forever — so on Bare a
|
|
30
|
+
wedged worker is abandoned (never joined), while Node's `terminate()`
|
|
31
|
+
genuinely kills. Joining a healthy worker is only safe after its channel end
|
|
32
|
+
has closed (`drainAndReap`), because the channel teardown itself needs the
|
|
33
|
+
host's event loop.
|
|
34
|
+
- A fresh thread's module loader reads plain files only. Spawning a thread
|
|
35
|
+
whose entry lives inside a stowed `.bundle` aborts the whole process, but a
|
|
36
|
+
`.bundle` path _as the entry itself_ boots fine. Every stowed harness —
|
|
37
|
+
desktop (dev and packaged) and mobile — must therefore stage the worker as
|
|
38
|
+
its own bundle and name it via `QVAC_PYODIDE_WORKER_ENTRY`; until a host
|
|
39
|
+
sets it, a bundled harness falls back to the pre-worker in-process path
|
|
40
|
+
rather than crash, so the worker only runs under the harness test suites.
|
|
41
|
+
|
|
42
|
+
## Alternatives Considered
|
|
43
|
+
|
|
44
|
+
- **In-process with a watchdog thread writing the interrupt buffer**: keeps
|
|
45
|
+
the harness thread blocked during runs and cannot serve a user abort (the
|
|
46
|
+
event loop that would receive it is the blocked one).
|
|
47
|
+
- **Subprocess CPython**: no wasm sandbox, not portable to mobile (iOS forbids
|
|
48
|
+
spawning processes).
|
|
49
|
+
|
|
50
|
+
## Consequences
|
|
51
|
+
|
|
52
|
+
- The worker thread is an availability boundary, not a security boundary:
|
|
53
|
+
python can still reach the worker realm's globals via `from js import …`, so
|
|
54
|
+
a python grant keeps shell-level trust (see `scope.ts`).
|
|
55
|
+
- Anything that changes worker/entry module paths must keep the
|
|
56
|
+
bundled-harness fallback and the `QVAC_PYODIDE_WORKER_ENTRY` override
|
|
57
|
+
working, or every bundled host crashes on the first python run.
|
|
58
|
+
- The in-process path survives for injected test loaders only; fakes cannot
|
|
59
|
+
cross a thread boundary. Worker transport tests load a fake interpreter
|
|
60
|
+
inside the thread via `QVAC_PYODIDE_TEST_LOADER`.
|
|
61
|
+
- Revisit if Bare threads gain forced termination, or when the packaging
|
|
62
|
+
pipeline ships the staged worker bundle (the fallback then becomes dead
|
|
63
|
+
code).
|
|
64
|
+
|
|
65
|
+
## When Agents Should Read This
|
|
66
|
+
|
|
67
|
+
Read this when changing the paths, workflows, runtime boundaries, or platform
|
|
68
|
+
behavior listed for this ADR in `docs/decisions/INDEX.md`.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: ADR-2026-08-17-harness-hook-pipeline
|
|
3
|
+
status: active
|
|
4
|
+
introduced_by: PR-000
|
|
5
|
+
supersedes: []
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Harness hooks fold a whole round before any call runs, and fail closed
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
`assistant-harness` exposes four hook phases — `runStart`, `preToolCall`, `postToolCall`,
|
|
13
|
+
`runEnd` — registered in a single process-wide array (`harnessHooks`) and driven by
|
|
14
|
+
`HookPipeline` from inside `completion()`. Hooks receive a deep JSON clone of their payload and
|
|
15
|
+
influence the run only through what they return: new `args` for a pre-hook, a new `result` for a
|
|
16
|
+
post-hook, or a `HookRejection` (`{ error, guidance: true }`).
|
|
17
|
+
|
|
18
|
+
Two ordering rules are durable:
|
|
19
|
+
|
|
20
|
+
- **Pre-hooks fold the whole round before any call in that round executes.** The assistant turn
|
|
21
|
+
written to history replays the folded calls, so history can never disagree with what ran.
|
|
22
|
+
- **Post-hooks fire only after a real `tool.execute()`.** Refused, denied, invalid, guarded,
|
|
23
|
+
capped, and unknown calls never reach a post-hook.
|
|
24
|
+
|
|
25
|
+
A pre-hook may also _repair_ a call: the `args` it returns replace the model's own, and the
|
|
26
|
+
payload carries the tool's declared `parameters` so the repair is schema-driven rather than
|
|
27
|
+
per-tool. `ArgRepairHook` is registered first for that reason — it decodes a structured parameter
|
|
28
|
+
a small model handed over as text (fenced, newline-broken, or missing its outer closer) before
|
|
29
|
+
approval, the activity log, and `execute()` read the call, so all three see the same canonical
|
|
30
|
+
args and the tool itself keeps a strict reader.
|
|
31
|
+
|
|
32
|
+
Failure is closed, not fatal. A throwing tool hook becomes model-facing guidance and the loop
|
|
33
|
+
continues; a throwing `runStart`/`runEnd` hook is logged and ignored, because run authorization
|
|
34
|
+
lives in the policy layer, not here.
|
|
35
|
+
|
|
36
|
+
## Context
|
|
37
|
+
|
|
38
|
+
Tool calls already pass through validation, the trusted-source guard, effect classification, and
|
|
39
|
+
the approval memo. Extending any of those for cross-cutting concerns (redaction, arg rewriting,
|
|
40
|
+
telemetry, per-tool filtering) would have meant editing the gate chain for every new concern.
|
|
41
|
+
|
|
42
|
+
The round-level fold exists because the assistant turn is written to history once, before the
|
|
43
|
+
calls run. Folding per call would let a pre-hook mutate arg N after the turn already recorded the
|
|
44
|
+
model's original wrapper — history and execution would diverge, and the model would be trained by
|
|
45
|
+
its own record to re-emit the un-mutated form.
|
|
46
|
+
|
|
47
|
+
Deep cloning exists because payloads are the run's live objects: model-emitted args, history-bound
|
|
48
|
+
results, the message list. A hook that mutates one in place would leak into the run without
|
|
49
|
+
returning anything, which makes the "influence only through return values" contract unenforceable.
|
|
50
|
+
Payloads are JSON-domain, so a `JSON.parse(JSON.stringify(...))` round-trip severs every reference;
|
|
51
|
+
non-JSON values (`undefined`, functions, `BigInt`, cycles) pass through as-is rather than
|
|
52
|
+
withholding output that already ran.
|
|
53
|
+
|
|
54
|
+
## Alternatives Considered
|
|
55
|
+
|
|
56
|
+
- **Per-call fold (pre(N) after post(N-1)).** Rejected: it desynchronizes the assistant turn from
|
|
57
|
+
what executes. Accepted cost of the round fold is that pre-hooks fire for calls a later gate
|
|
58
|
+
(skill lock, search cap, abort) never runs.
|
|
59
|
+
- **Hooks that throw abort the run.** Rejected: a buggy third-party hook would become a run-level
|
|
60
|
+
denial of service. Failing closed on the _call_ keeps the run recoverable.
|
|
61
|
+
- **In-place mutation of the live payload.** Rejected: no way to distinguish a deliberate change
|
|
62
|
+
from an accidental one, and no way to reject a hook's edit.
|
|
63
|
+
- **Structured-clone / a clone library.** Rejected: payloads are already JSON-domain; a dependency
|
|
64
|
+
buys nothing a `JSON` round-trip does not.
|
|
65
|
+
|
|
66
|
+
## Consequences
|
|
67
|
+
|
|
68
|
+
- A new hook phase must state whether it fires before or after execution, and post-phase hooks
|
|
69
|
+
must never fire for a call that did not execute.
|
|
70
|
+
- Rejection wording must never match the SDK wedge patterns in `models/sdk-faults.ts` — those mark
|
|
71
|
+
a dead native context for eviction, not guidance for the model. A post-hook rejection says the
|
|
72
|
+
tool already executed, so the model does not retry a side effect.
|
|
73
|
+
- With no hook registered a run pays nothing, not even a payload clone. Keep that: `harnessHooks`
|
|
74
|
+
is empty by default.
|
|
75
|
+
- Registration is a process-wide array with no ordering, priority, or per-agent scoping. That is
|
|
76
|
+
deliberate and should be revisited only when a real consumer needs it.
|
|
77
|
+
- Hooks cannot bypass approval, the trusted-source guard, or the denial memo — those gates run on
|
|
78
|
+
the folded args, after the hook.
|
|
79
|
+
|
|
80
|
+
## When Agents Should Read This
|
|
81
|
+
|
|
82
|
+
Read this when changing the paths listed for this ADR in `docs/decisions/INDEX.md` — the tool loop
|
|
83
|
+
in `capabilities/completion.ts`, anything under `lib/hooks/`, or any new gate that must order
|
|
84
|
+
itself against the fold.
|