@miller-tech/uap 2.39.0 → 2.44.1
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/config/capacity-policy.json +25 -0
- package/config/model-profiles/qwen38.json +8 -7
- package/config/model-registry.json +668 -20
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/deliver.js +1 -1
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/models.d.ts +6 -0
- package/dist/cli/models.d.ts.map +1 -1
- package/dist/cli/models.js +11 -2
- package/dist/cli/models.js.map +1 -1
- package/dist/cli/verify.d.ts +9 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +15 -3
- package/dist/cli/verify.js.map +1 -1
- package/dist/delivery/acceptance-judge.d.ts +12 -0
- package/dist/delivery/acceptance-judge.d.ts.map +1 -1
- package/dist/delivery/acceptance-judge.js +109 -8
- package/dist/delivery/acceptance-judge.js.map +1 -1
- package/dist/inference/model-files.d.ts.map +1 -1
- package/dist/inference/model-files.js +6 -1
- package/dist/inference/model-files.js.map +1 -1
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +14 -2
- package/dist/models/types.js.map +1 -1
- package/docs/INDEX.md +1 -0
- package/docs/architecture/adr/0015-session-hook-model-convergence-as-standing-consent.md +68 -0
- package/docs/benchmarks/PROMPT_UPLIFT_BLENDER.md +161 -0
- package/docs/benchmarks/README.md +1 -0
- package/docs/guides/LOCAL_MODELS.md +42 -0
- package/package.json +1 -1
- package/scripts/lib/droid-sync-contracts.sh +114 -0
- package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
- package/src/policies/enforcers/delivery_enforcement.py +34 -12
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
- package/templates/hooks/pre-tool-use-bash.sh +34 -3
- package/templates/hooks/stop.sh +39 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +16 -4
- package/tools/agents/tests/test_delivery_enforcement_write_complexity.py +26 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Prompt Uplift Field Study: a 2-bit Model Passes a Pixel Gate (2026-11)
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-11-12 · **Subject:** `abliterated-iq2_xs` on a frozen Blender build
|
|
4
|
+
contract, driven by bespoke harness tooling (the repo's UAP install is 2.39.1; UAP
|
|
5
|
+
supplies the memory store and worktree workflow here, not the harness — there is no
|
|
6
|
+
uplift subsystem to attribute a result to)
|
|
7
|
+
**Scope:** one measured prompt-uplift run against a frozen multi-gate acceptance
|
|
8
|
+
contract, and the four transferable lessons it produced. This is a dated study, not
|
|
9
|
+
a feature doc: the harness lives in the experiment repo, not here. It is an
|
|
10
|
+
*uncontrolled field run* in the sense defined by
|
|
11
|
+
[benchmarks/README.md](README.md) — one model, no control arm, prompt changed at every
|
|
12
|
+
step — so it shows magnitude and mechanism, not causation; quote the paired harness for
|
|
13
|
+
causal claims. For the evergreen mechanism the study informs, see
|
|
14
|
+
[guides/DELIVER.md](../guides/DELIVER.md) (iterate against real completion gates) and
|
|
15
|
+
[guides/LOCAL_MODELS.md](../guides/LOCAL_MODELS.md).
|
|
16
|
+
|
|
17
|
+
## The experiment
|
|
18
|
+
|
|
19
|
+
Task: write a Blender Python script that builds a rigged, animated, game-ready
|
|
20
|
+
robot. Acceptance is two independent gates, and the model is asked to produce exactly
|
|
21
|
+
one artifact, its build script:
|
|
22
|
+
|
|
23
|
+
- **Structural gate** — 12 checks: exactly 19 bones, 4 clips, under 12k tris, feet
|
|
24
|
+
on the floor, weapon grip at the palm, every mesh bound to the rig, FBX
|
|
25
|
+
round-trip, and so on. Hash-pinned in `.uap/gate-lock.json` (`a519af41c595…`),
|
|
26
|
+
along with the human-readable contract `gates.md`.
|
|
27
|
+
- **Visual gate** — 8 pixel-level checks over 36 rendered PNGs (4 clips × 3 keyframe
|
|
28
|
+
positions × 3 ortho cameras). Pixels are classified by HSV hue, so the gate reads
|
|
29
|
+
the render the way a reviewer would: is the weapon at the hand, are the feet on the
|
|
30
|
+
floor line, is any part floating off in space. Added after the lock file was
|
|
31
|
+
written, so it is pinned by recorded hash (`7672fece1d0e…`) rather than by
|
|
32
|
+
`gate-lock.json` — see the caveat under lesson 1.
|
|
33
|
+
|
|
34
|
+
Model: `abliterated-iq2_xs` (a ~2-bit quantized small model), temp 0, **first attempt
|
|
35
|
+
only, no repair turns**. Each stage is one prompt revision, scored by `prompt_sha`, so
|
|
36
|
+
a score is attributable to an exact prompt.
|
|
37
|
+
|
|
38
|
+
`n` counts scored attempts — every row in `.uap/uplift/results.jsonl` except the three
|
|
39
|
+
`--dry-prompt` rows, which print the prompt and record no score. A build that crashes is
|
|
40
|
+
scored 0, not excluded. `12/12` is the structural gate; `both gates` means 12/12 *and*
|
|
41
|
+
8/8 on the same first attempt.
|
|
42
|
+
|
|
43
|
+
| stage | prompt change | n | 12/12 | both gates | `sys` crashes |
|
|
44
|
+
|-------|---------------|---|-------|------------|---------------|
|
|
45
|
+
| bare | raw prompt | 5 | 0 | n/a (no visual gate) | 0 |
|
|
46
|
+
| A-acceptance | 12-check contract + API crib sheet | 38 | 23 | n/a (no visual gate) | 0 |
|
|
47
|
+
| B-visual | + render/visual gate | 3 | 3 | 0 (visual 3/8–4/8) | 0 |
|
|
48
|
+
| B-visual-v2 | visual rules rewritten | 3 | 0 (all three builds crashed) | 0 | 0 |
|
|
49
|
+
| B-visual-v3 | tightened geometry rules | 3 | 2 | **0** (visual 6/8 twice) | 0 |
|
|
50
|
+
| B-visual-v4 | vertical limb chains, blade inside hand | 9 | 1 | **1** | 4 |
|
|
51
|
+
| B-visual-v5 | verbatim import header + copied fix loop | 6 | **6** | **4 (67%)** | 0 |
|
|
52
|
+
|
|
53
|
+
First-attempt rate at *both* gates went 0/3 → 1/9 → 4/6 across three prompt
|
|
54
|
+
revisions, with no change to the model, the gates, or the temperature. v4's nine runs
|
|
55
|
+
split four ways: 4 `sys` crashes, 3 reasoning truncations, 1 built-but-incomplete
|
|
56
|
+
(11/12, 6/8), 1 clean pass.
|
|
57
|
+
|
|
58
|
+
## Four lessons
|
|
59
|
+
|
|
60
|
+
### 1. A gate the model can edit is not a gate
|
|
61
|
+
|
|
62
|
+
The lock file states the rule in its own header: the model "may NOT edit these files.
|
|
63
|
+
A hash mismatch fails the run." The runner copies the validator, the visual gate, the
|
|
64
|
+
render script and `gates.md` fresh into each attempt workspace before the build runs,
|
|
65
|
+
then executes the model's script there. That keeps the frozen originals in `tooling/`
|
|
66
|
+
out of the way, but nothing enforces the header's sentence at runtime: the model's
|
|
67
|
+
script runs as ordinary Blender Python with write access to its workspace, and the
|
|
68
|
+
workspace sits inside the same repo as the originals. Integrity was therefore
|
|
69
|
+
established *after* the fact: pinned hash matches live, and every workspace carried
|
|
70
|
+
byte-identical gate copies (one distinct hash across all 63 validator and 24
|
|
71
|
+
visual-gate copies).
|
|
72
|
+
|
|
73
|
+
Be precise about the strength of that. The runner copies the gates but does not
|
|
74
|
+
re-verify the hashes *inside* the run, and the workspace copy is writable, so the
|
|
75
|
+
guarantee here is audit-after-the-fact rather than runtime enforcement. That is enough
|
|
76
|
+
to make the pass rate falsifiable — a tampered gate would show up as a hash mismatch
|
|
77
|
+
when checked — but it is not the same as a read-only mount or a pre-flight assertion.
|
|
78
|
+
Closing that gap is cheap: recompute the workspace gate hashes after the model's
|
|
79
|
+
script has written and before the gates execute, and score the attempt 0 on mismatch.
|
|
80
|
+
Four `hashlib` calls in the runner turn "no tampering was found afterwards" into
|
|
81
|
+
"tampering could not have helped". The transferable part stands either way: without a
|
|
82
|
+
pinned verifier, a 67% pass rate is unfalsifiable, because the cheapest path to 12/12
|
|
83
|
+
is editing the checker. This is the
|
|
84
|
+
same `enforce, don't suggest` stance as everywhere else in UAP, applied to the verifier
|
|
85
|
+
rather than the producer.
|
|
86
|
+
|
|
87
|
+
### 2. Score the prompt, not the vibe
|
|
88
|
+
|
|
89
|
+
Every run records `prompt_sha` alongside its score, and prompt files compose with
|
|
90
|
+
`#extends <stage>` so a revision is a delta, not a rewrite. That is what made v4
|
|
91
|
+
diagnosable: the geometry rules demonstrably worked (attempt 5 hit both gates,
|
|
92
|
+
matching the offline geometry test that had been run before the stage shipped) while
|
|
93
|
+
the stage as a whole regressed. Without per-revision attribution, "v4 is worse than
|
|
94
|
+
v3" would have buried a working fix under an unrelated crash.
|
|
95
|
+
|
|
96
|
+
### 3. A regression can be a missing word, not a wrong idea
|
|
97
|
+
|
|
98
|
+
4 of 9 v4 runs died identically:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
NameError: name 'sys' is not defined. Did you forget to import 'sys'?
|
|
102
|
+
argv = sys.argv
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The composed prompt asks for an `argv` guard (`argv` appears 7 times) and never
|
|
106
|
+
contains the words `import sys` — 0 occurrences in 43,382 characters. So the import is
|
|
107
|
+
purely the model's own guess. Reading the generated headers confirms it: v3's three
|
|
108
|
+
runs each happened to write `import bpy, sys, os`, while v4's four crashing runs wrote
|
|
109
|
+
`import bpy` / `import math` / `from mathutils import …` and omitted `sys` — same
|
|
110
|
+
instruction, different coin flip. v5 states the header as four lines to copy verbatim,
|
|
111
|
+
`import sys` included, and the crash count went to 0/6 with the validator at 6/6.
|
|
112
|
+
|
|
113
|
+
The transferable part: when a prompt asks for a construct, state the import or
|
|
114
|
+
declaration that construct needs, as text to copy rather than prose to understand.
|
|
115
|
+
Small models rewrite headers; they do not reliably infer them.
|
|
116
|
+
|
|
117
|
+
### 4. Reasoning budget is a hard constraint, not a tuning knob
|
|
118
|
+
|
|
119
|
+
Three v4 runs left reasoning on: the model spent 17k–27k reasoning characters before
|
|
120
|
+
writing any code and hit `finish_reason=length` mid-table, at both 8k and 12k max
|
|
121
|
+
tokens, producing an unterminated tuple and a `SyntaxError`. All three scored 0.
|
|
122
|
+
|
|
123
|
+
The pattern holds across the whole log, not just v4. Of 67 scored runs, 15 had
|
|
124
|
+
reasoning on and 52 had it off. Every one of the 52 reasoning-off runs finished with
|
|
125
|
+
`finish=stop` — none truncated. All 8 truncated runs in the log had reasoning on, and
|
|
126
|
+
all 8 scored 0. Reasoning on did not become safe at a bigger budget either: the 7
|
|
127
|
+
reasoning-on runs that were given 32k–64k tokens did finish, and their best score was
|
|
128
|
+
11/12 — no reasoning-on run in the log ever cleared the structural gate. Meanwhile v5,
|
|
129
|
+
run with `--reasoning-effort none --max-tokens 12000`, scored 12/12 six times out of
|
|
130
|
+
six. A truncated run is a budget misconfiguration, not a model failure, and the budget
|
|
131
|
+
must be pinned in the invocation for scores to stay comparable across stages.
|
|
132
|
+
|
|
133
|
+
## Where the last two failures sit
|
|
134
|
+
|
|
135
|
+
Both remaining failures are single-rung and both are animation amplitude, not
|
|
136
|
+
geometry. A walk cycle lifted the feet 17 px where the limit is 12; an attack pose
|
|
137
|
+
rotated the blade edge-on so its pixels left the camera plane entirely (gap 999 means
|
|
138
|
+
"no weapon pixels found in that frame", not "drifted a little").
|
|
139
|
+
|
|
140
|
+
The structural gate measures the rest pose, so both builds pass 12/12 and fail the
|
|
141
|
+
pixel gate. Its `feet-on-floor` rung reads the lowest world-Z of any `Foot*` vertex at
|
|
142
|
+
`|z| <= 0.005` m — a static measurement — while the visual gate measures the lift in
|
|
143
|
+
every rendered frame. That gap, a structural check that never exercises the animated
|
|
144
|
+
state, is the argument for the visual gate existing at all.
|
|
145
|
+
|
|
146
|
+
For scale, re-rendering the offline reference build (the hand-written geometry proof
|
|
147
|
+
that shipped the v4/v5 rules) through the same pixel gate puts its peak foot lift at
|
|
148
|
+
7 px and its island ratio at 0.088 of the bbox diagonal (limit 0.30); the four passing
|
|
149
|
+
v5 runs land at 3–9 px. The failing walk cycle at 17 px is not marginally off — it is
|
|
150
|
+
over twice the reference and 5 px past the limit, on the one rung it misses.
|
|
151
|
+
|
|
152
|
+
## Reproduce
|
|
153
|
+
|
|
154
|
+
Harness, prompts, findings, and per-attempt workspaces live in the experiment repo
|
|
155
|
+
(`blender-test-1`): `tooling/uplift_runner.py`, `.uap/uplift/prompts/`,
|
|
156
|
+
`.uap/uplift/FINDINGS-B-visual-v{4,5}.md`, `.uap/uplift/runs/`.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
python3 tooling/uplift_runner.py --stage B-visual-v5 --attempt N \
|
|
160
|
+
--reasoning-effort none --max-tokens 12000
|
|
161
|
+
```
|
|
@@ -61,6 +61,7 @@ authoritative.
|
|
|
61
61
|
| [Token Optimization](TOKEN_OPTIMIZATION.md) | Where the token savings come from |
|
|
62
62
|
| [Accuracy Analysis](ACCURACY_ANALYSIS.md) | Success-rate and error analysis |
|
|
63
63
|
| [Comprehensive Benchmarks](COMPREHENSIVE_BENCHMARKS.md) | Extended measurements |
|
|
64
|
+
| [Prompt Uplift Field Study](PROMPT_UPLIFT_BLENDER.md) | Uncontrolled field run: prompt revisions only, one 2-bit model, frozen structural + pixel gates (0/3 → 4/6). Descriptive, no control arm |
|
|
64
65
|
|
|
65
66
|
## Task suites and raw runs
|
|
66
67
|
|
|
@@ -171,6 +171,48 @@ For the full picture — the `uap proxy` lifecycle, the reliability guardrails
|
|
|
171
171
|
that keep a small model from wedging, security, and serving recipes — see the
|
|
172
172
|
**[Inference Proxy guide](PROXY.md)**.
|
|
173
173
|
|
|
174
|
+
## Droid (Factory) custom models — auto-populated from the live arm
|
|
175
|
+
|
|
176
|
+
Droid reads its model list from `customModels` in `~/.factory/settings.json`
|
|
177
|
+
(user) and `<project>/.factory/settings.json` (project). Those entries are
|
|
178
|
+
static per file — Droid does not discover local servers — so a backend swap
|
|
179
|
+
would otherwise leave every Droid instance pinning a retired model id.
|
|
180
|
+
|
|
181
|
+
[`scripts/sync-local-agent-configs.sh`](../../scripts/sync-local-agent-configs.sh)
|
|
182
|
+
closes that gap, and never transcribes the alias: it reads the model id and
|
|
183
|
+
geometry from `config/model-profiles/qwen38.json` (the same single source of
|
|
184
|
+
truth the rails suite drift-checks against the live `/props` on :8080), then:
|
|
185
|
+
|
|
186
|
+
- migrates every retired local id (the registry's `retired_local_aliases`
|
|
187
|
+
list — the single source, never transcribed into the script) onto the
|
|
188
|
+
alias the current arm serves — proxy entries to `:4000`, direct-Responses
|
|
189
|
+
entries stay on `:8080`;
|
|
190
|
+
- **adds** the entry when a settings file has no local qwen model at all, so
|
|
191
|
+
every Droid instance (user-level settings cover all projects) can call it;
|
|
192
|
+
- sizes entries to the profile's per-session cap (114688 via the proxy, the
|
|
193
|
+
131072 pool on the direct path) and the proxy tool-turn cap (32768);
|
|
194
|
+
- pins `baseModelId` on Anthropic-protocol entries (default
|
|
195
|
+
`claude-sonnet-4-6`, override `DROID_ANTHROPIC_BASE_MODEL`) so the entry
|
|
196
|
+
inherits model-specific prompts, tools, and Script-mode eligibility instead
|
|
197
|
+
of the "custom model does not map to a built-in" fallback behavior.
|
|
198
|
+
|
|
199
|
+
For "at any given time" freshness, run it with `MODELS_ONLY=1`: that mode
|
|
200
|
+
converges **only** the `.factory` custom-model lists, takes the alias from the
|
|
201
|
+
LIVE backend (`/props` on :8080, profile fallback when unreachable), and
|
|
202
|
+
touches nothing else. The wiring is machine-local, not shipped:
|
|
203
|
+
`.factory/` is gitignored, so the repo cannot carry the hook — wire it once
|
|
204
|
+
per machine (project or user `.factory/settings.json`) as a `SessionStart`
|
|
205
|
+
hook running exactly that, per [ADR
|
|
206
|
+
0015](../architecture/adr/0015-session-hook-model-convergence-as-standing-consent.md),
|
|
207
|
+
and every Droid session starts with the qwen list pointed at whatever arm is
|
|
208
|
+
actually serving. `DRY_RUN=1` shows the plan; a run backs up only what it
|
|
209
|
+
changes.
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
bash scripts/sync-local-agent-configs.sh # full operator sync
|
|
213
|
+
MODELS_ONLY=1 bash scripts/sync-local-agent-configs.sh # droid lists only
|
|
214
|
+
```
|
|
215
|
+
|
|
174
216
|
## Related
|
|
175
217
|
|
|
176
218
|
- [Deploy Batching](./DEPLOY_BATCHING.md) — what `uap deliver --deploy` queues.
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# droid-sync-contracts.sh — SHARED CONTRACT for Droid (Factory) model sync.
|
|
4
|
+
# -----------------------------------------------------------------------------
|
|
5
|
+
# Every Droid/Factory instance — the user-level ~/.factory/config.json and
|
|
6
|
+
# ~/.factory/settings.json AND the repo-level .factory/settings.json — must
|
|
7
|
+
# auto-converge its customModels onto the LIVE local qwen3.8 backend instead
|
|
8
|
+
# of drifting onto retired ids and stale hosts.
|
|
9
|
+
#
|
|
10
|
+
# This file is the single place the installer and the sandbox test agree on:
|
|
11
|
+
# the variable names and their defaults. It is SOURCED (never executed) by
|
|
12
|
+
# scripts/sync-local-agent-configs.sh and by scripts/test-sync-sandbox.sh, so
|
|
13
|
+
# the installer and the assertions read ONE contract instead of two
|
|
14
|
+
# transcriptions of it — a number or path transcribed in both places is
|
|
15
|
+
# exactly the failure class that left the sandbox suite red through the
|
|
16
|
+
# 1-rail era. The FUNCTION bodies live in sync-local-agent-configs.sh
|
|
17
|
+
# (sync_droid_models / droid_sync_lock); the sandbox asserts them textually
|
|
18
|
+
# against that file, so this contract stays free of dead stub code.
|
|
19
|
+
#
|
|
20
|
+
# CONTRACT RULES (this file is frozen once accepted):
|
|
21
|
+
# * Sourcing is SIDE-EFFECT FREE: no I/O, no mkdir, no network, no writes.
|
|
22
|
+
# Every value is a `: "${VAR:=default}"` assignment, so a caller may
|
|
23
|
+
# pre-set any of them (the sandbox does, to point at a throwaway $HOME)
|
|
24
|
+
# and the caller's value always wins.
|
|
25
|
+
# * Nothing here depends on bash features beyond 4.2.
|
|
26
|
+
#
|
|
27
|
+
# shellcheck shell=bash
|
|
28
|
+
#
|
|
29
|
+
|
|
30
|
+
# Double-source guard: the installer and the sandbox may both source this.
|
|
31
|
+
if [ -n "${_UAP_DROID_SYNC_CONTRACTS_LOADED:-}" ]; then
|
|
32
|
+
return 0 2>/dev/null || true
|
|
33
|
+
fi
|
|
34
|
+
_UAP_DROID_SYNC_CONTRACTS_LOADED=1
|
|
35
|
+
|
|
36
|
+
# ===========================================================================
|
|
37
|
+
# SHARED VARIABLES — all overridable from the environment.
|
|
38
|
+
# ===========================================================================
|
|
39
|
+
|
|
40
|
+
# MODELS_ONLY — when "1", the sync touches ONLY the customModels/custom_models
|
|
41
|
+
# arrays of the Droid config files. No service operations (systemctl/curl),
|
|
42
|
+
# no proxy env requirement, no other agent configs. This is the mode the
|
|
43
|
+
# auto-convergence session hook runs in: it must be safe to fire on every
|
|
44
|
+
# session start, on any machine state.
|
|
45
|
+
# "0" (default) = full sync "1" = customModels only
|
|
46
|
+
: "${MODELS_ONLY:=0}"
|
|
47
|
+
|
|
48
|
+
# REPO_ROOT — absolute path of the checkout the sync is running against.
|
|
49
|
+
# Derived from this file's own location (scripts/lib/ -> repo root), never
|
|
50
|
+
# from $PWD, so a hook fired from a subdirectory still resolves the repo
|
|
51
|
+
# .factory/settings.json correctly. A caller (the installer, the sandbox)
|
|
52
|
+
# may pre-set it to redirect the whole sync at a throwaway tree.
|
|
53
|
+
: "${REPO_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
54
|
+
|
|
55
|
+
# PROJECT_FACTORY_SET — the repo-level Droid settings file whose customModels
|
|
56
|
+
# must converge alongside the user-level ones. camelCase spelling
|
|
57
|
+
# ("customModels"), same shape as ~/.factory/settings.json.
|
|
58
|
+
: "${PROJECT_FACTORY_SET:=$REPO_ROOT/.factory/settings.json}"
|
|
59
|
+
|
|
60
|
+
# DROID_ANTHROPIC_BASE_MODEL — the Factory BUILT-IN catalog id written into
|
|
61
|
+
# `baseModelId` of anthropic-provider entries. A custom Anthropic-provider
|
|
62
|
+
# model inherits that built-in's prompt/tool-call behavior; without it Droid
|
|
63
|
+
# warns the entry "does not map to a built-in model". claude-sonnet-4-6 is the
|
|
64
|
+
# choice because it accepts reasoning-effort "off" (the proxy sends none) and
|
|
65
|
+
# speaks the Anthropic Messages class the :4000 proxy serves. This is NOT a
|
|
66
|
+
# local model id — it must never carry a qwen id.
|
|
67
|
+
: "${DROID_ANTHROPIC_BASE_MODEL:=claude-sonnet-4-6}"
|
|
68
|
+
|
|
69
|
+
# DROID_SYNC_LOCK — lock file serialising concurrent Droid config writes
|
|
70
|
+
# (session-start hook + manual installer run + a second Droid window).
|
|
71
|
+
# Without it two writers read-modify-write the same JSON and one set of
|
|
72
|
+
# customModels silently loses the other's update. Defaults under
|
|
73
|
+
# $XDG_RUNTIME_DIR so it is per-user and per-boot; /tmp is the fallback for
|
|
74
|
+
# environments without XDG_RUNTIME_DIR (containers, CI sandboxes).
|
|
75
|
+
# $XDG_RUNTIME_DIR is per-user (0700 tmpfs); the /tmp fallback is
|
|
76
|
+
# world-writable, so key it per-uid there or any local user could hold the
|
|
77
|
+
# lock forever and silently disable convergence.
|
|
78
|
+
if [ -n "${XDG_RUNTIME_DIR:-}" ]; then
|
|
79
|
+
: "${DROID_SYNC_LOCK:=$XDG_RUNTIME_DIR/uap-droid-sync.lock}"
|
|
80
|
+
else
|
|
81
|
+
: "${DROID_SYNC_LOCK:=/tmp/uap-droid-sync-$UID.lock}"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
# ===========================================================================
|
|
85
|
+
# FUNCTION CONTRACT (bodies live in sync-local-agent-configs.sh)
|
|
86
|
+
# ===========================================================================
|
|
87
|
+
# droid_sync_lock — acquire fd 9 on $DROID_SYNC_LOCK with `flock -n`.
|
|
88
|
+
# returns 0 = lock held for this script; 1 = another sync holds it.
|
|
89
|
+
#
|
|
90
|
+
# sync_droid_models <FILE> <CREATE> — converge ONE Droid config file.
|
|
91
|
+
# FILE : a Droid settings.json (camelCase customModels) or legacy
|
|
92
|
+
# config.json (snake_case custom_models).
|
|
93
|
+
# CREATE : "1" = create the file (with the canonical entry) when absent —
|
|
94
|
+
# only settings targets use this; the legacy file is patched
|
|
95
|
+
# in place or left alone. "0" = patch only when present.
|
|
96
|
+
# Reads from the caller's scope: MODEL_ALIAS, PROXY_URL, DIRECT_URL,
|
|
97
|
+
# CTX_SESSION, CTX_POOL, MAX_OUTPUT, DROID_ANTHROPIC_BASE_MODEL,
|
|
98
|
+
# PROXY_TOKEN (via UAP_SYNC_TOKEN env), DRY_RUN.
|
|
99
|
+
# Migration rules (idempotent; no change => file left byte-identical):
|
|
100
|
+
# * entries whose model is a RETIRED local id — or already the live
|
|
101
|
+
# alias, or model-less but pointed at :4000/:8080 — converge onto
|
|
102
|
+
# MODEL_ALIAS;
|
|
103
|
+
# * route by existing shape: baseUrl with :8080 -> direct strata route
|
|
104
|
+
# (pool-sized); :4000 WITHOUT /v1 -> Anthropic Messages root (proxy
|
|
105
|
+
# root + baseModelId + apiKey only if absent, session-sized); anything
|
|
106
|
+
# else local -> proxy chat route ($PROXY_URL/v1 + token);
|
|
107
|
+
# * cloud models (anything else, including the :8317 router entries) are
|
|
108
|
+
# deliberate user choices and are never repointed;
|
|
109
|
+
# * a settings.json with NO local entry after migration gains the
|
|
110
|
+
# canonical proxy entry (add-if-missing);
|
|
111
|
+
# * the 192.168.1.165:8317 split-brain host is fixed everywhere;
|
|
112
|
+
# * writes are atomic (tmp file in the same directory + os.replace),
|
|
113
|
+
# under the flock, with backups taken by the caller's backup().
|
|
114
|
+
# returns 0 always (a per-file failure is a printed WARN, not an abort).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -193,16 +193,32 @@ def _trivial_edit_chars() -> int:
|
|
|
193
193
|
|
|
194
194
|
def _changed_chars(args: dict) -> int | None:
|
|
195
195
|
"""Total changed characters for an Edit/MultiEdit, or None when it can't be
|
|
196
|
-
measured (e.g. a whole-file Write) — None means 'not trivially small'.
|
|
196
|
+
measured (e.g. a whole-file Write) — None means 'not trivially small'.
|
|
197
|
+
|
|
198
|
+
Harnesses spell the anchor fields three ways: Claude Code sends
|
|
199
|
+
old_string/new_string, some SDKs oldString/newString, and Droid (Factory)
|
|
200
|
+
old_str/new_str. An unrecognized spelling measured as None — read as a
|
|
201
|
+
whole-file rewrite — which force-escalated every Droid Edit as complex
|
|
202
|
+
regardless of size (observed live 2026-11-12: a 4-line comment insert was
|
|
203
|
+
refused as 'a whole-file rewrite that replaces most of a substantial
|
|
204
|
+
file'). grab() recognizes all three spellings at both call sites."""
|
|
205
|
+
def grab(d: dict) -> tuple[object, object]:
|
|
206
|
+
old = d.get("old_string") or d.get("oldString") or d.get("old_str")
|
|
207
|
+
new = d.get("new_string") or d.get("newString") or d.get("new_str")
|
|
208
|
+
return old, new
|
|
197
209
|
def pair(o: object, n: object) -> int:
|
|
198
210
|
return len(str(o or "")) + len(str(n or ""))
|
|
199
211
|
if "edits" in args and isinstance(args["edits"], list):
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
total, measured = 0, False
|
|
213
|
+
for e in args["edits"]:
|
|
214
|
+
if not isinstance(e, dict):
|
|
215
|
+
continue
|
|
216
|
+
old, new = grab(e)
|
|
217
|
+
if old is not None or new is not None:
|
|
218
|
+
measured = True
|
|
219
|
+
total += pair(old, new)
|
|
220
|
+
return total if measured else None
|
|
221
|
+
old, new = grab(args)
|
|
206
222
|
if old is not None or new is not None:
|
|
207
223
|
return pair(old, new)
|
|
208
224
|
return None # Write (full content) — not a trivial edit
|
|
@@ -232,7 +248,7 @@ def _write_cost(args: dict, root: Path) -> int | None:
|
|
|
232
248
|
"""
|
|
233
249
|
content = args.get("content")
|
|
234
250
|
if content is None:
|
|
235
|
-
content = args.get("new_string") or args.get("newString")
|
|
251
|
+
content = args.get("new_string") or args.get("newString") or args.get("new_str")
|
|
236
252
|
if content is None:
|
|
237
253
|
return None
|
|
238
254
|
new_len = len(str(content))
|
|
@@ -460,8 +476,11 @@ def _escalation_block(rel: str, kind: str, detail: str, args: dict) -> None:
|
|
|
460
476
|
intent = {
|
|
461
477
|
k: v
|
|
462
478
|
for k, v in (
|
|
463
|
-
|
|
464
|
-
|
|
479
|
+
# Same three spellings as _changed_chars/_write_cost — an
|
|
480
|
+
# escalated Droid edit (old_str/new_str) must be recorded as a
|
|
481
|
+
# replayable intent, not silently dropped to None.
|
|
482
|
+
("old_string", args.get("old_string") or args.get("oldString") or args.get("old_str")),
|
|
483
|
+
("new_string", args.get("new_string") or args.get("newString") or args.get("new_str")),
|
|
465
484
|
("content", args.get("content")),
|
|
466
485
|
)
|
|
467
486
|
if isinstance(v, str)
|
|
@@ -868,8 +887,11 @@ def main() -> None:
|
|
|
868
887
|
intent_payload = {
|
|
869
888
|
k: v
|
|
870
889
|
for k, v in (
|
|
871
|
-
|
|
872
|
-
|
|
890
|
+
# Same three spellings as _changed_chars/_write_cost — an
|
|
891
|
+
# escalated Droid edit (old_str/new_str) must be recorded as a
|
|
892
|
+
# replayable intent, not silently dropped to None.
|
|
893
|
+
("old_string", args.get("old_string") or args.get("oldString") or args.get("old_str")),
|
|
894
|
+
("new_string", args.get("new_string") or args.get("newString") or args.get("new_str")),
|
|
873
895
|
("content", args.get("content")),
|
|
874
896
|
)
|
|
875
897
|
if isinstance(v, str)
|
|
Binary file
|
|
@@ -14,8 +14,18 @@ fi
|
|
|
14
14
|
# Read tool input from stdin (JSON)
|
|
15
15
|
INPUT=$(cat)
|
|
16
16
|
|
|
17
|
-
# Extract command from tool_input
|
|
18
|
-
|
|
17
|
+
# Extract command from tool_input, with bounded retries. Under a full
|
|
18
|
+
# parallel suite (or a busy box) a jq spawn can fail transiently, and a
|
|
19
|
+
# fail-open on that would silently allow exactly the edits this guard
|
|
20
|
+
# exists to block — measured as the recurring jq '.version = "9.9.9"'
|
|
21
|
+
# semver-suite flake (3 strikes on 2026-11-12: two CI runs, one local).
|
|
22
|
+
# Fail-open stays the LAST resort for genuinely unparseable input.
|
|
23
|
+
CMD=""
|
|
24
|
+
for _attempt in 1 2 3 4 5; do
|
|
25
|
+
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
|
|
26
|
+
[ -n "$CMD" ] && break
|
|
27
|
+
sleep 0.05
|
|
28
|
+
done
|
|
19
29
|
|
|
20
30
|
# If we can't determine the command, fail open
|
|
21
31
|
if [ -z "$CMD" ]; then
|
|
@@ -178,7 +188,28 @@ fi
|
|
|
178
188
|
# That is a READ. Splitting on ; && || | and newline keeps every real edit
|
|
179
189
|
# (each has sed/awk/jq and package.json inside one statement) and drops the
|
|
180
190
|
# cross-statement coincidence. Verified against both corpora before and after.
|
|
181
|
-
|
|
191
|
+
#
|
|
192
|
+
# Evaluated with BUILT-INS ONLY: parameter expansion for the statement split,
|
|
193
|
+
# [[ =~ ]] for the match. An earlier `printf | tr | grep -qE` pipeline looked
|
|
194
|
+
# equivalent but had a load-shaped hole: in an `if` condition a grep that fails
|
|
195
|
+
# to spawn (exit 2/126/127 under fork pressure) is indistinguishable from a
|
|
196
|
+
# no-match (exit 1), so the rule silently failed OPEN under the full parallel
|
|
197
|
+
# suite — the canary still refused, so it looked like a genuine allow. That was
|
|
198
|
+
# the recurring semver-suite flake (3 strikes on 2026-11-12: two CI runs, one
|
|
199
|
+
# local version-bump gate). Zero forks here means no spawn can hiccup, and no
|
|
200
|
+
# pipeline stage can mask a match.
|
|
201
|
+
_semv_split="${CMD//[;|&]/$'\n'}"
|
|
202
|
+
_semv_a='(sed|awk).*package\.json.*version'
|
|
203
|
+
_semv_b='(sed|awk).*version.*package\.json'
|
|
204
|
+
_semv_c='jq.*\.version.*package\.json'
|
|
205
|
+
_semv_hit=""
|
|
206
|
+
while IFS= read -r _semv_stmt; do
|
|
207
|
+
if [[ "$_semv_stmt" =~ $_semv_a || "$_semv_stmt" =~ $_semv_b || "$_semv_stmt" =~ $_semv_c ]]; then
|
|
208
|
+
_semv_hit="yes"
|
|
209
|
+
break
|
|
210
|
+
fi
|
|
211
|
+
done <<< "$_semv_split"
|
|
212
|
+
if [ -n "$_semv_hit" ]; then
|
|
182
213
|
echo "BLOCKED [semver-versioning]: Manual package.json version edits are prohibited. Use: npm run version:patch, version:minor, or version:major. See policies/semver-versioning.md" >&2
|
|
183
214
|
exit 2
|
|
184
215
|
fi
|
package/templates/hooks/stop.sh
CHANGED
|
@@ -233,10 +233,49 @@ if [ "$CODE_CHANGED" = "true" ] && [ "${UAP_VERIFY_ON_STOP:-1}" != "0" ] && comm
|
|
|
233
233
|
fi
|
|
234
234
|
fi
|
|
235
235
|
set -e
|
|
236
|
+
# A green verify (or a gate that never ran) resets the consecutive-block
|
|
237
|
+
# counter below — a fresh red streak starts from zero.
|
|
238
|
+
if [ "$VERIFY_RC" = "0" ]; then
|
|
239
|
+
rm -f "${PROJECT_DIR}/.uap/stop-gate/blocks" 2>/dev/null || true
|
|
240
|
+
fi
|
|
236
241
|
# RC 1 = a REAL gate failure (code is broken) → block. Everything else fails
|
|
237
242
|
# OPEN: RC 3 = infra (gate timed out / couldn't spawn), 124 = outer timeout,
|
|
238
243
|
# 127 = missing, any other = internal — none should ever wedge a session.
|
|
239
244
|
if [ "$VERIFY_RC" = "1" ]; then
|
|
245
|
+
# Bounded consecutive-block relief. The re-entrancy guard at the top
|
|
246
|
+
# relents only when the harness sends stop_hook_active:true (the Claude
|
|
247
|
+
# Code contract). Harnesses that send no Stop payload (observed: Factory
|
|
248
|
+
# Droid on pay2u, 2026-10-18) never trip that guard, so a persistently-red
|
|
249
|
+
# gate — e.g. max-fidelity interaction fail-closed on a repo with entry
|
|
250
|
+
# pages but no manifest — became an unbounded Stop→continue→Stop loop:
|
|
251
|
+
# exactly the wedge this hook's fail-open design promises cannot happen.
|
|
252
|
+
# Count consecutive RC-1 blocks per project; relent (allow the stop, with
|
|
253
|
+
# the red verdict still on record) after UAP_STOP_BLOCK_LIMIT (default 3).
|
|
254
|
+
# A block older than an hour starts a fresh count: the counter tracks a
|
|
255
|
+
# LOOP, not history — a new session gets a fresh budget.
|
|
256
|
+
mkdir -p "${PROJECT_DIR}/.uap/stop-gate" 2>/dev/null || true
|
|
257
|
+
BLOCK_FILE="${PROJECT_DIR}/.uap/stop-gate/blocks"
|
|
258
|
+
BLOCK_COUNT=0
|
|
259
|
+
BLOCK_TS=0
|
|
260
|
+
if [ -f "$BLOCK_FILE" ]; then
|
|
261
|
+
BLOCK_COUNT="$(head -1 "$BLOCK_FILE" 2>/dev/null || echo 0)"
|
|
262
|
+
BLOCK_TS="$(tail -1 "$BLOCK_FILE" 2>/dev/null || echo 0)"
|
|
263
|
+
fi
|
|
264
|
+
case "$BLOCK_COUNT" in ''|*[!0-9]*) BLOCK_COUNT=0 ;; esac
|
|
265
|
+
case "$BLOCK_TS" in ''|*[!0-9]*) BLOCK_TS=0 ;; esac
|
|
266
|
+
NOW="$(date +%s 2>/dev/null || echo 0)"
|
|
267
|
+
case "$NOW" in ''|*[!0-9]*) NOW=0 ;; esac
|
|
268
|
+
if [ "$NOW" -gt 0 ] && [ "$((NOW - BLOCK_TS))" -gt 3600 ]; then
|
|
269
|
+
BLOCK_COUNT=0
|
|
270
|
+
fi
|
|
271
|
+
BLOCK_LIMIT="${UAP_STOP_BLOCK_LIMIT:-3}"
|
|
272
|
+
case "$BLOCK_LIMIT" in ''|*[!0-9]*) BLOCK_LIMIT=3 ;; esac
|
|
273
|
+
if [ "$BLOCK_COUNT" -ge "$BLOCK_LIMIT" ]; then
|
|
274
|
+
echo ""
|
|
275
|
+
echo "## RUNTIME/USER-PATH GATE still red after $BLOCK_COUNT consecutive blocks — allowing stop so the session cannot wedge. The verdict above stays on record; fix the failure in a follow-up session. (Bound: UAP_STOP_BLOCK_LIMIT=$BLOCK_LIMIT; full bypass: UAP_VERIFY_ON_STOP=0.)"
|
|
276
|
+
exit 0
|
|
277
|
+
fi
|
|
278
|
+
printf '%s\n%s\n' "$((BLOCK_COUNT + 1))" "$NOW" > "$BLOCK_FILE" 2>/dev/null || true
|
|
240
279
|
echo ""
|
|
241
280
|
echo "## RUNTIME/USER-PATH GATE FAILED — the code does not run or does not work for a real user"
|
|
242
281
|
echo "$VERIFY_OUT"
|
|
Binary file
|
|
@@ -1621,11 +1621,23 @@ PROXY_EMPTY_TOOL_LOOP_BREAK = int(
|
|
|
1621
1621
|
# ever-growing transcript (observed: ctx 936%, model emits tool_calls 18/min
|
|
1622
1622
|
# despite tool_choice=auto). After this many consecutive turns at/above the
|
|
1623
1623
|
# ratio, strip tools entirely so the only possible response is a terminal text
|
|
1624
|
-
# summary (end_turn), which ends the client's agentic loop.
|
|
1625
|
-
#
|
|
1626
|
-
#
|
|
1624
|
+
# summary (end_turn), which ends the client's agentic loop.
|
|
1625
|
+
#
|
|
1626
|
+
# Ratio sits above the merely-full band but inside the runaway band. A
|
|
1627
|
+
# merely-full session tops out near 100-141%: a client that honestly believes
|
|
1628
|
+
# a ~200k window (PROXY_CLIENT_ASSUMED_WINDOW) and compacts at 92.5% sends at
|
|
1629
|
+
# most ~185k against a 131,072 rail. A window-blind runaway plateaus wherever
|
|
1630
|
+
# its own belief caps it, and that plateau can sit far below 300%: live
|
|
1631
|
+
# 2026-11-06 (droid + flash-next, cognition-engine), a client with no
|
|
1632
|
+
# maxContextLimit (assumed 250k) ramped 132% -> 198% in 4 minutes and then sat
|
|
1633
|
+
# at 190-198% for 4+ HOURS — every turn CRITICAL-PRUNEd blind to ~6 messages
|
|
1634
|
+
# (~24-40k actually sent upstream), findings evaporating, the model re-reading
|
|
1635
|
+
# the same files — because the 3.0 gate was written for 936% runaways and this
|
|
1636
|
+
# one never crossed 200%. 1.6 clears the merely-full band (141% plus one
|
|
1637
|
+
# ~19k-token turn of headroom) while ending that class of treadmill within
|
|
1638
|
+
# STREAK turns. 0 disables.
|
|
1627
1639
|
PROXY_RAW_CTX_FINALIZE_RATIO = float(
|
|
1628
|
-
os.environ.get("PROXY_RAW_CTX_FINALIZE_RATIO", "
|
|
1640
|
+
os.environ.get("PROXY_RAW_CTX_FINALIZE_RATIO", "1.6")
|
|
1629
1641
|
)
|
|
1630
1642
|
PROXY_RAW_CTX_FINALIZE_STREAK = int(
|
|
1631
1643
|
os.environ.get("PROXY_RAW_CTX_FINALIZE_STREAK", "2")
|
|
@@ -94,6 +94,32 @@ class WriteComplexityTest(unittest.TestCase):
|
|
|
94
94
|
self.assertEqual(small[0], 0, small[1])
|
|
95
95
|
self.assertEqual(big[0], 2, big[1])
|
|
96
96
|
|
|
97
|
+
def test_droid_edit_spelling_is_measured_the_same(self):
|
|
98
|
+
# Droid (Factory) sends old_str/new_str. Before 2026-11-12 those
|
|
99
|
+
# spellings measured as None — "whole-file rewrite" — so EVERY Droid
|
|
100
|
+
# Edit escalated as complex regardless of size. Same sizes as the
|
|
101
|
+
# Claude-spelling case must get the same verdicts.
|
|
102
|
+
root = project()
|
|
103
|
+
target = os.path.join(root, "src", "thing.ts")
|
|
104
|
+
small = run(root, {"file_path": target, "old_str": "a" * 20, "new_str": "b" * 20}, op="Edit")
|
|
105
|
+
big = run(root, {"file_path": target, "old_str": "a" * 300, "new_str": "b" * 300}, op="Edit")
|
|
106
|
+
self.assertEqual(small[0], 0, small[1])
|
|
107
|
+
self.assertEqual(big[0], 2, big[1])
|
|
108
|
+
|
|
109
|
+
def test_multiedit_droid_spelling_sums_across_edits(self):
|
|
110
|
+
root = project()
|
|
111
|
+
target = os.path.join(root, "src", "multi.ts")
|
|
112
|
+
code_ok, out_ok = run(root, {"file_path": target, "edits": [
|
|
113
|
+
{"old_str": "a" * 20, "new_str": "b" * 20},
|
|
114
|
+
{"old_str": "c" * 20, "new_str": "d" * 20},
|
|
115
|
+
]}, op="MultiEdit")
|
|
116
|
+
code_big, _ = run(root, {"file_path": target, "edits": [
|
|
117
|
+
{"old_str": "a" * 200, "new_str": "b" * 200},
|
|
118
|
+
{"old_str": "c" * 200, "new_str": "d" * 200},
|
|
119
|
+
]}, op="MultiEdit")
|
|
120
|
+
self.assertEqual(code_ok, 0, out_ok)
|
|
121
|
+
self.assertEqual(code_big, 2)
|
|
122
|
+
|
|
97
123
|
def test_nature_exemptions_are_unchanged(self):
|
|
98
124
|
root = project()
|
|
99
125
|
docs = run(root, {"file_path": os.path.join(root, "docs", "n.md"), "content": "x" * 9000})
|