@miller-tech/uap 1.217.0 → 1.219.0
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/llama-patches/dflash2-pr27342.patch +1365 -0
- package/config/llama-profiles/qwen38-27b-dflash2.env +211 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/deliver.js +16 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/guided-setup.d.ts.map +1 -1
- package/dist/cli/guided-setup.js +8 -6
- package/dist/cli/guided-setup.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +32 -0
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/wizard-config.d.ts +2 -2
- package/dist/cli/wizard-config.d.ts.map +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +22 -7
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/coordination/reactor.d.ts +8 -0
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +43 -12
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/mcp-router/tools/deliver.js +1 -1
- package/dist/mcp-router/tools/deliver.js.map +1 -1
- package/dist/types/config.d.ts +40 -16
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +10 -2
- package/dist/types/config.js.map +1 -1
- package/docs/guides/QWEN36_LLAMACPP.md +96 -37
- package/docs/reference/CONFIGURATION_REFERENCE.md +44 -8
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/delivery_enforcement.py +226 -8
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/deliver_autoroute.py +2 -2
- package/templates/hooks/escalation_tracker.py +252 -0
- package/templates/hooks/stop.sh +10 -0
- package/templates/hooks/uap-policy-gate.sh +5 -5
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +112 -4
|
@@ -172,43 +172,102 @@ overriding `enable_thinking=true`.
|
|
|
172
172
|
Do **not** switch a Qwen profile to `embedded` — the model's own baked-in template
|
|
173
173
|
still carries the `'System message must be at the beginning.'` raise.
|
|
174
174
|
|
|
175
|
-
## Speculative decoding:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
175
|
+
## Speculative decoding: DFlash2 is the default
|
|
176
|
+
|
|
177
|
+
`config/llama-profiles/qwen38-27b-dflash2.env` is the default profile as of
|
|
178
|
+
2026-08-20. `qwen38-27b-mtp.env` is kept as the fallback — same model, same
|
|
179
|
+
context, same slot count; only the speculative-decoding config differs.
|
|
180
|
+
|
|
181
|
+
Measured on this card (RTX 3090, sm_86, temp 0, 4 prompts, 1032 tokens per arm,
|
|
182
|
+
**same binary across arms** so the spec config is the only variable):
|
|
183
|
+
|
|
184
|
+
| arm | aggregate tok/s | accept rate | tokens/verify step | |
|
|
185
|
+
|---|---|---|---|---|
|
|
186
|
+
| no speculation, `-np 1` | 41.3 | — | 1.00 | baseline |
|
|
187
|
+
| `draft-mtp` n-max 2, `-np 1` | 59.3 | 93.6% | 2.68 | |
|
|
188
|
+
| **DFlash2 n-max 4, `-np 1`** | **68.7** | 67.4% | 3.68 | **1.16×** |
|
|
189
|
+
| `draft-mtp` n-max 2, `-np 2` | 66.0 | 92.5% | 2.67 | |
|
|
190
|
+
| **DFlash2 n-max 4, `-np 2`** | **89.8** | 66.5% | 3.66 | **1.36×** |
|
|
191
|
+
|
|
192
|
+
The `-np 2` rows are two *genuinely concurrent* requests — the production shape.
|
|
193
|
+
Re-verified end-to-end through `run-llama-server-continuity.sh` with the shipped
|
|
194
|
+
profile: **92.6 tok/s**, tool calls parsing, peak 23459 MiB.
|
|
195
|
+
|
|
196
|
+
**Read `1.36×` as a blend, not a constant.** That run pushed 4 prompts through a
|
|
197
|
+
2-slot pool, so the last prompt always finishes alone and the wall covers two
|
|
198
|
+
different regimes. Decomposing it from the server's own `launch_slot_`/`release`
|
|
199
|
+
timeline:
|
|
200
|
+
|
|
201
|
+
| regime | `draft-mtp` | DFlash2 n-max 4 | ratio |
|
|
202
|
+
|---|---|---|---|
|
|
203
|
+
| both slots decoding | 76.4 tok/s | 130.3 tok/s | **1.71×** |
|
|
204
|
+
| one request in flight on the 2-slot server | 57.9 tok/s | 60.4 tok/s | **1.04×** |
|
|
205
|
+
| the measured 4-prompt burst (what `1.36×` reports) | 66.0 | 89.8 | 1.36× |
|
|
206
|
+
|
|
207
|
+
So the gain is real but duty-cycle dependent: it is ~1.7× while both slots are
|
|
208
|
+
busy and close to nothing when only one request is in flight. Add prompts to the
|
|
209
|
+
burst and the headline drifts up toward 1.71×; remove them and it drifts down.
|
|
210
|
+
The `-np 1` row above is a `--parallel 1` server, which this service never runs —
|
|
211
|
+
for the single-request case on the real 2-slot server use the 1.04–1.2× range,
|
|
212
|
+
not 1.16×.
|
|
213
|
+
|
|
214
|
+
Two caveats on the numbers, both unclosed:
|
|
215
|
+
|
|
216
|
+
* **Every measurement was taken at `temperature 0.0`**, while this profile serves
|
|
217
|
+
at `LLAMA_TEMP=0.7 / TOP_P=0.8 / TOP_K=20`. Greedy is the most favourable
|
|
218
|
+
setting for speculative decoding, and it flatters a 4-token block more than a
|
|
219
|
+
2-token one, so `1.36×` is an upper bound for the sampling that actually runs.
|
|
220
|
+
* **Acceptance is strongly prompt-dependent for DFlash2 and flat for MTP.**
|
|
221
|
+
Across the six prompts ever run, DFlash2 ranged 0.40–0.95 and `draft-mtp` sat
|
|
222
|
+
in a 0.91–0.97 band. The two lowest-acceptance prompts (prose explanation) were
|
|
223
|
+
never run against `draft-mtp`, so the regime where DFlash2 could lose is
|
|
224
|
+
untested. `n-max 7 < n-max 4` is direct evidence that drafting cost dominates
|
|
225
|
+
once acceptance falls.
|
|
226
|
+
|
|
227
|
+
Two results here are worth keeping, because both contradict what the upstream
|
|
228
|
+
material predicts:
|
|
229
|
+
|
|
230
|
+
* **DFlash2 does not degrade under concurrency on this card — it improves.** The
|
|
231
|
+
PR thread reports throughput collapsing at `-np >= 2`; here the advantage grows
|
|
232
|
+
from 1.16× to 1.36× going from one slot to two. Do not carry that assumption to
|
|
233
|
+
other hardware in either direction; measure it.
|
|
234
|
+
* **`n-max 4` beats `n-max 7`** (76.7 vs 69.8 tok/s), even though n-max 7 accepts
|
|
235
|
+
a longer block (4.86 vs 3.87 tokens/step). Acceptance falls from 72% to 55% as
|
|
236
|
+
the block lengthens, so the extra drafting outruns what it buys. The drafter
|
|
237
|
+
card's published acceptance length of 5.39 is real but is not the throughput
|
|
238
|
+
optimum.
|
|
239
|
+
|
|
240
|
+
### The cost, and when to roll back
|
|
241
|
+
|
|
242
|
+
Peak VRAM in the production shape is **23459 / 24576 MiB — about 1.1 GiB free**.
|
|
243
|
+
There is **no paired measurement of `draft-mtp` taken the same way**, so treat the
|
|
244
|
+
drafter's marginal cost as un-measured rather than as the ~0.8 GiB the weights
|
|
245
|
+
imply: the table above reports ~0.95 GiB free for `draft-mtp` at this same tier,
|
|
246
|
+
which cannot both be true and leave DFlash2 roomier. Re-measure both profiles
|
|
247
|
+
with one harness before relying on either figure. Note also that 23459 was
|
|
248
|
+
sampled with `nvidia-smi` on a 1 s loop over a ~10 s run whose longest prefill
|
|
249
|
+
was 540 tokens — it never exercised a long-context prefill on two slots, which is
|
|
250
|
+
the real peak event at 131072/slot. The drafter's weights (Q4_K_M,
|
|
251
|
+
1.1 GB) are essentially the whole difference: its KV cache stays around 80 MiB
|
|
252
|
+
because it carries its own 2048-token sliding window rather than scaling with the
|
|
253
|
+
target's 131072/slot. That is what lets this fit without cutting context or slots
|
|
254
|
+
— and it is why `LLAMA_DRAFT_CTX_SIZE` must stay unset.
|
|
255
|
+
|
|
256
|
+
This card has a history of *global* kernel OOM events selecting this service for
|
|
257
|
+
its RSS. The GPU side is now thinner too. **If the server starts failing to load
|
|
258
|
+
or dying, switch `~/.config/uap/llama-server.env` back to `qwen38-27b-mtp.env`
|
|
259
|
+
before changing anything else** — that is what the fallback profile is for.
|
|
260
|
+
|
|
261
|
+
### The engine is an unmerged PR
|
|
262
|
+
|
|
263
|
+
[llama.cpp#27342](https://github.com/ggml-org/llama.cpp/pull/27342) is **open**.
|
|
264
|
+
The build is a cherry-pick of `5ecbe1ac1` onto `f466cfa38` (zero conflicts),
|
|
265
|
+
vendored as `config/llama-patches/dflash2-pr27342.patch` so it is reproducible if
|
|
266
|
+
the build worktree is lost. The merged `draft-dflash` **v1** engine cannot
|
|
267
|
+
substitute: a v2 checkpoint declares the same `general.architecture = "dflash"`,
|
|
268
|
+
so auto-detect selects `draft-dflash` and then fails on the 23 conv/selector
|
|
269
|
+
tensors and `dflash.conv_*` / `dflash.selector_*` metadata keys v1 has no loader
|
|
270
|
+
for. Return to plain upstream master once the PR lands.
|
|
212
271
|
|
|
213
272
|
## Point UAP at it
|
|
214
273
|
|
|
@@ -42,24 +42,60 @@ uap config wizard # interactive expert configurator (also: uap set
|
|
|
42
42
|
| | |
|
|
43
43
|
|---|---|
|
|
44
44
|
| **Where** | `.uap.json` |
|
|
45
|
-
| **Type** | enum (block \| advisory \| off) |
|
|
45
|
+
| **Type** | enum (block \| advisory \| off \| escalate) |
|
|
46
46
|
| **Default** | `block` |
|
|
47
47
|
|
|
48
|
-
How the delivery gate treats a direct source edit outside `uap deliver`. `block` refuses it (exit 2), `advisory` warns but allows
|
|
48
|
+
How the delivery gate treats a direct source edit outside `uap deliver`. `block` refuses it (exit 2) and routes it to deliver; `escalate` lets direct edits land and routes to deliver only as an ESCALATION — after `delivery.escalateAfterFailures` consecutive gate failures, `delivery.escalateAfterEdits` edits to one file with no green gate, or a single change above `delivery.complexEditChars`; `advisory` warns but allows; `off` disables the gate. (The `UAP_ENFORCE_DELIVERY` env var overrides this at runtime.)
|
|
49
49
|
|
|
50
|
-
**Recommendation:** `
|
|
50
|
+
**Recommendation:** `escalate` for everyday agent work: small and medium edits go straight to disk and are judged by the project's own build/test + the verify hooks, while deliver is reserved for complex multi-file work and for when direct editing is demonstrably not working. `block` only when EVERY change must pass the convergence loop (unattended hands-free runs); `advisory` when a capable human is driving.
|
|
51
51
|
|
|
52
52
|
### `delivery.localMode`
|
|
53
53
|
|
|
54
54
|
| | |
|
|
55
55
|
|---|---|
|
|
56
56
|
| **Where** | `.uap.json` |
|
|
57
|
-
| **Type** | enum (advisory \| deliver \| block) |
|
|
57
|
+
| **Type** | enum (advisory \| deliver \| block \| escalate) |
|
|
58
58
|
| **Default** | `advisory` |
|
|
59
59
|
|
|
60
|
-
How local-model
|
|
60
|
+
How a local-model session resolves a `block` posture. `escalate` lets direct edits land and escalates to deliver on evidence (failed gates, churn, size); `deliver` routes every substantive edit through the convergence loop; `block` forbids raw edits; `advisory` warns.
|
|
61
61
|
|
|
62
|
-
**Recommendation:** `
|
|
62
|
+
**Recommendation:** `escalate` when a local model does the writing — deliver becomes the escalation point instead of a tax on every small task; `deliver` only for unattended runs where each change must be converged; `advisory` for exploratory work.
|
|
63
|
+
|
|
64
|
+
### `delivery.escalateAfterFailures`
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
|---|---|
|
|
68
|
+
| **Where** | `.uap.json` |
|
|
69
|
+
| **Type** | number |
|
|
70
|
+
| **Default** | `2` |
|
|
71
|
+
|
|
72
|
+
Escalate mode: after this many CONSECUTIVE verification failures (uap verify, the stop hook, or a build/test command observed by the hooks) since the last green gate, the next non-trivial direct source edit is refused and routed to `uap deliver` with the failure attached. 0 disables this trigger.
|
|
73
|
+
|
|
74
|
+
**Recommendation:** Leave at 2: one failure is normal iteration; two in a row with no green in between is the signal that direct editing is not converging.
|
|
75
|
+
|
|
76
|
+
### `delivery.escalateAfterEdits`
|
|
77
|
+
|
|
78
|
+
| | |
|
|
79
|
+
|---|---|
|
|
80
|
+
| **Where** | `.uap.json` |
|
|
81
|
+
| **Type** | number |
|
|
82
|
+
| **Default** | `10` |
|
|
83
|
+
|
|
84
|
+
Escalate mode: when one source file has been edited this many times with no green gate in between, the next edit to it escalates to `uap deliver` (thrashing guard). Any passing verification resets the count. 0 disables this trigger.
|
|
85
|
+
|
|
86
|
+
**Recommendation:** Leave at 10 — generous enough for honest iteration, low enough to stop a model that is patching the same file line by line.
|
|
87
|
+
|
|
88
|
+
### `delivery.complexEditChars`
|
|
89
|
+
|
|
90
|
+
| | |
|
|
91
|
+
|---|---|
|
|
92
|
+
| **Where** | `.uap.json` |
|
|
93
|
+
| **Type** | number |
|
|
94
|
+
| **Default** | `6000` |
|
|
95
|
+
|
|
96
|
+
Escalate mode: a single edit or whole-file write above this many characters (or a write that guts a substantial file) is treated as complex work and routed through `uap deliver` so it is verified rather than trusted blind. 0 disables this trigger.
|
|
97
|
+
|
|
98
|
+
**Recommendation:** 6000 characters is roughly a whole module; anything that size deserves the convergence loop. Raise it for generated/vendored code, lower it for a weak executor.
|
|
63
99
|
|
|
64
100
|
### `deliver.escalateModel`
|
|
65
101
|
|
|
@@ -78,7 +114,7 @@ Stronger model id for deliver escalation ladders: repair passes, the phase-5 esc
|
|
|
78
114
|
| | |
|
|
79
115
|
|---|---|
|
|
80
116
|
| **Where** | shell env |
|
|
81
|
-
| **Type** | enum (block \| advisory \| off) |
|
|
117
|
+
| **Type** | enum (block \| advisory \| off \| escalate) |
|
|
82
118
|
| **Default** | `block` |
|
|
83
119
|
|
|
84
120
|
Runtime override of the delivery gate read by the hooks/enforcers from the shell env. Takes precedence over `delivery.enforcement`.
|
|
@@ -217,7 +253,7 @@ Base URL of an OpenAI-compatible, image_url-capable endpoint used for aesthetic
|
|
|
217
253
|
| **Type** | string |
|
|
218
254
|
| **Default** | `` |
|
|
219
255
|
|
|
220
|
-
Model id sent to the vision endpoint for aesthetic review (e.g.
|
|
256
|
+
Model id sent to the vision endpoint for aesthetic review (e.g. qwen3.8-27b).
|
|
221
257
|
|
|
222
258
|
**Recommendation:** Set by `uap setup` to your local vision model. Required for blocking vision review under `max` fidelity.
|
|
223
259
|
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -17,6 +17,24 @@ Escape hatches (always honored, even in block mode):
|
|
|
17
17
|
- UAP_DELIVER_ACTIVE=1 set by the deliver loop for its own subprocesses
|
|
18
18
|
- UAP_DELIVER_BYPASS=1 explicit operator override for a sanctioned manual edit
|
|
19
19
|
|
|
20
|
+
ESCALATE mode (`delivery.enforcement: escalate`, or `delivery.localMode:
|
|
21
|
+
escalate` for a local-model session under block): deliver is an ESCALATION
|
|
22
|
+
POINT, not the path every edit is forced through. Ordinary source edits go
|
|
23
|
+
straight to disk (the project's own build/test + the verify hooks judge them)
|
|
24
|
+
and the gate routes to `uap deliver` only when the evidence says direct editing
|
|
25
|
+
is not working or the change is too big to land unverified:
|
|
26
|
+
|
|
27
|
+
- failures : >= delivery.escalateAfterFailures (default 2) consecutive
|
|
28
|
+
verification failures since the last green gate
|
|
29
|
+
- churn : one file edited >= delivery.escalateAfterEdits (default 10)
|
|
30
|
+
times with no green gate in between (thrashing)
|
|
31
|
+
- complex : a single edit/write above delivery.complexEditChars (default
|
|
32
|
+
6000) or a whole-file rewrite that guts a substantial file
|
|
33
|
+
|
|
34
|
+
The evidence lives in .uap/escalation-state.json, written by the
|
|
35
|
+
escalation_tracker.py hook (uap verify / stop hook / build+test shell results)
|
|
36
|
+
and cleared by any green verification or a landed deliver run.
|
|
37
|
+
|
|
20
38
|
Exempt by construction: non-source files, docs/configs/scripts/policies, test
|
|
21
39
|
files (deliver protects those itself), and tooling dot-dirs.
|
|
22
40
|
"""
|
|
@@ -277,12 +295,195 @@ def _is_local_model_session() -> bool:
|
|
|
277
295
|
# Back-compat: UAP_DELIVER_LOCAL_ADVISORY=0 maps to "block".
|
|
278
296
|
def _local_mode() -> str:
|
|
279
297
|
m = os.environ.get("UAP_DELIVER_LOCAL_MODE", "").lower()
|
|
280
|
-
if m in {"advisory", "deliver", "block"}:
|
|
298
|
+
if m in {"advisory", "deliver", "block", "escalate"}:
|
|
281
299
|
return m
|
|
282
300
|
adv = os.environ.get("UAP_DELIVER_LOCAL_ADVISORY", "on").lower()
|
|
283
301
|
return "advisory" if adv not in {"0", "off", "false", "no", ""} else "block"
|
|
284
302
|
|
|
285
303
|
|
|
304
|
+
def _effective_mode() -> str:
|
|
305
|
+
"""The gate posture for THIS operation: block | advisory | off | escalate.
|
|
306
|
+
|
|
307
|
+
`UAP_ENFORCE_DELIVERY` (config-authoritative via the policy gate) wins. A
|
|
308
|
+
local-model session under `block` is then resolved through
|
|
309
|
+
`UAP_DELIVER_LOCAL_MODE`: advisory -> advisory, escalate -> escalate,
|
|
310
|
+
deliver/block -> block (keep the route-through-deliver block).
|
|
311
|
+
"""
|
|
312
|
+
mode = os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower()
|
|
313
|
+
if mode == "block" and _is_local_model_session():
|
|
314
|
+
lm = _local_mode()
|
|
315
|
+
if lm in {"advisory", "escalate"}:
|
|
316
|
+
return lm
|
|
317
|
+
return mode
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── ESCALATE mode: evidence-driven routing ───────────────────────────────────
|
|
321
|
+
ESCALATION_STATE_FILE = ".uap/escalation-state.json"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _delivery_cfg_int(root: Path, key: str, env: str, default: int) -> int:
|
|
325
|
+
"""An integer budget from the environment, else `.uap.json` delivery.<key>."""
|
|
326
|
+
raw = os.environ.get(env)
|
|
327
|
+
if raw is not None:
|
|
328
|
+
try:
|
|
329
|
+
return max(0, int(raw))
|
|
330
|
+
except ValueError:
|
|
331
|
+
pass
|
|
332
|
+
try:
|
|
333
|
+
import json as _json
|
|
334
|
+
cfg_root = Path(os.environ.get("UAP_MAIN_ROOT") or root)
|
|
335
|
+
cfg = _json.loads((cfg_root / ".uap.json").read_text(encoding="utf-8"))
|
|
336
|
+
value = (cfg.get("delivery") or {}).get(key)
|
|
337
|
+
if isinstance(value, bool):
|
|
338
|
+
return default
|
|
339
|
+
if isinstance(value, (int, float)):
|
|
340
|
+
return max(0, int(value))
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
return default
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _escalation_state(root: Path) -> dict:
|
|
347
|
+
"""The tracker's evidence, or an empty record. Evidence older than
|
|
348
|
+
UAP_DELIVER_ESCALATION_TTL_SEC (default 6h) is ignored: yesterday's red
|
|
349
|
+
build must not gate today's first edit."""
|
|
350
|
+
import json as _json
|
|
351
|
+
import time as _time
|
|
352
|
+
empty = {"failures": 0, "edits_since_green": {}, "last_failure": None}
|
|
353
|
+
try:
|
|
354
|
+
st = _json.loads((root / ESCALATION_STATE_FILE).read_text(encoding="utf-8"))
|
|
355
|
+
if not isinstance(st, dict):
|
|
356
|
+
return empty
|
|
357
|
+
except Exception:
|
|
358
|
+
return empty
|
|
359
|
+
try:
|
|
360
|
+
ttl = int(os.environ.get("UAP_DELIVER_ESCALATION_TTL_SEC", "21600"))
|
|
361
|
+
except ValueError:
|
|
362
|
+
ttl = 21600
|
|
363
|
+
# Age the FAILURE evidence by when it was recorded, not by the last edit:
|
|
364
|
+
# `updated` moves on every direct edit, which would keep a two-day-old red
|
|
365
|
+
# gate alive for as long as the agent keeps typing.
|
|
366
|
+
lf = st.get("last_failure") if isinstance(st.get("last_failure"), dict) else None
|
|
367
|
+
anchor = int((lf or {}).get("ts") or st.get("updated") or 0)
|
|
368
|
+
if ttl > 0 and int(_time.time()) - anchor > ttl:
|
|
369
|
+
return empty
|
|
370
|
+
st.setdefault("failures", 0)
|
|
371
|
+
st.setdefault("edits_since_green", {})
|
|
372
|
+
return st
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _record_escalation_edit(root: Path, rel: str) -> None:
|
|
376
|
+
"""Count a direct edit against the file's churn budget (best-effort)."""
|
|
377
|
+
import json as _json
|
|
378
|
+
import time as _time
|
|
379
|
+
path = root / ESCALATION_STATE_FILE
|
|
380
|
+
try:
|
|
381
|
+
st = _json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
382
|
+
if not isinstance(st, dict):
|
|
383
|
+
st = {}
|
|
384
|
+
except Exception:
|
|
385
|
+
st = {}
|
|
386
|
+
edits = st.get("edits_since_green") if isinstance(st.get("edits_since_green"), dict) else {}
|
|
387
|
+
edits[rel] = int(edits.get(rel, 0)) + 1
|
|
388
|
+
st["edits_since_green"] = edits
|
|
389
|
+
st.setdefault("failures", 0)
|
|
390
|
+
st["updated"] = int(_time.time())
|
|
391
|
+
try:
|
|
392
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
tmp = path.with_suffix(".tmp")
|
|
394
|
+
tmp.write_text(_json.dumps(st, indent=1), encoding="utf-8")
|
|
395
|
+
os.replace(tmp, path)
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _escalation_trigger(root: Path, rel: str, changed: "int | None", *, size_known: bool = True):
|
|
401
|
+
"""(kind, human detail) when this edit must escalate to deliver, else None.
|
|
402
|
+
|
|
403
|
+
Order matters: a failing build is the strongest evidence, then thrashing on
|
|
404
|
+
one file, then sheer size. `changed is None` with size_known means a
|
|
405
|
+
whole-file rewrite the cost model refused (gutting) -> complex."""
|
|
406
|
+
st = _escalation_state(root)
|
|
407
|
+
n_fail = int(st.get("failures") or 0)
|
|
408
|
+
fail_budget = _delivery_cfg_int(root, "escalateAfterFailures", "UAP_DELIVER_ESCALATE_FAILURES", 2)
|
|
409
|
+
if fail_budget > 0 and n_fail >= fail_budget:
|
|
410
|
+
lf = st.get("last_failure") or {}
|
|
411
|
+
detail = str(lf.get("detail") or "").strip().replace("\n", " | ")[:400]
|
|
412
|
+
src = str(lf.get("source") or "gate")
|
|
413
|
+
return ("failures", f"{n_fail} consecutive verification failure(s) since the last green gate (last: {src}: {detail or 'no detail'})")
|
|
414
|
+
edits = st.get("edits_since_green") or {}
|
|
415
|
+
n_edits = int(edits.get(rel, 0)) if isinstance(edits, dict) else 0
|
|
416
|
+
churn_budget = _delivery_cfg_int(root, "escalateAfterEdits", "UAP_DELIVER_ESCALATE_EDITS", 10)
|
|
417
|
+
if churn_budget > 0 and n_edits >= churn_budget:
|
|
418
|
+
return ("churn", f"'{rel}' has been edited {n_edits} times with no green gate in between")
|
|
419
|
+
if size_known:
|
|
420
|
+
big = _delivery_cfg_int(root, "complexEditChars", "UAP_DELIVER_COMPLEX_EDIT_CHARS", 6000)
|
|
421
|
+
if changed is None:
|
|
422
|
+
return ("complex", "a whole-file rewrite that replaces most of a substantial file")
|
|
423
|
+
if big > 0 and changed > big:
|
|
424
|
+
return ("complex", f"a {changed}-character change (budget {big}) — a whole module's worth")
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _escalation_block(rel: str, kind: str, detail: str, args: dict) -> None:
|
|
429
|
+
"""Refuse the direct edit and hand the agent the deliver escalation, with
|
|
430
|
+
the evidence that triggered it. Terse and imperative on purpose (weak local
|
|
431
|
+
models retry or hallucinate completion on passive prose)."""
|
|
432
|
+
if kind == "failures":
|
|
433
|
+
msg = (
|
|
434
|
+
f"ESCALATE: {detail}. Direct edits are paused: editing '{rel}' again would repeat "
|
|
435
|
+
"what is not working. Call the `deliver` tool NOW with a one-line task that names the "
|
|
436
|
+
"failing gate and the fix (or run: uap deliver \"fix: <the failure>\"). Deliver "
|
|
437
|
+
"converges the change against the real gates; direct edits resume once it lands or "
|
|
438
|
+
"the gates go green. Do NOT retry this edit."
|
|
439
|
+
)
|
|
440
|
+
hint = f"fix the failing verification and make the gates green: {detail[:240]}"
|
|
441
|
+
intent = None
|
|
442
|
+
elif kind == "churn":
|
|
443
|
+
msg = (
|
|
444
|
+
f"ESCALATE: {detail} — you are thrashing. Stop patching it line by line. Call the "
|
|
445
|
+
"`deliver` tool with a one-line description of the INTENDED END STATE of this file "
|
|
446
|
+
"(what it must do, which tests must pass). Direct edits to it resume after a green "
|
|
447
|
+
"gate. Do NOT retry this edit."
|
|
448
|
+
)
|
|
449
|
+
hint = f"bring {rel} to a working state that passes the project gates"
|
|
450
|
+
intent = None
|
|
451
|
+
else:
|
|
452
|
+
msg = (
|
|
453
|
+
f"ESCALATE: this is {detail}. A change this size must land through the `deliver` "
|
|
454
|
+
"tool (or: uap deliver \"<one-line description>\") so it is verified against the "
|
|
455
|
+
"gates, not trusted blind. Your content is recorded as a replayable intent. Do NOT "
|
|
456
|
+
"retry this edit directly."
|
|
457
|
+
)
|
|
458
|
+
hint = f"implement the intended change to {rel}"
|
|
459
|
+
intent = {
|
|
460
|
+
k: v
|
|
461
|
+
for k, v in (
|
|
462
|
+
("old_string", args.get("old_string", args.get("oldString"))),
|
|
463
|
+
("new_string", args.get("new_string", args.get("newString"))),
|
|
464
|
+
("content", args.get("content")),
|
|
465
|
+
)
|
|
466
|
+
if isinstance(v, str)
|
|
467
|
+
} or None
|
|
468
|
+
emit(False, msg, route="deliver", deliverHint=hint, editIntent=intent, escalation=kind)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _handle_escalate_edit(args: dict, rel: str, root: Path, changed: "int | None") -> None:
|
|
472
|
+
trig = _escalation_trigger(root, rel, changed)
|
|
473
|
+
if trig is not None:
|
|
474
|
+
_escalation_block(rel, trig[0], trig[1], args)
|
|
475
|
+
return
|
|
476
|
+
_record_escalation_edit(root, rel)
|
|
477
|
+
print(
|
|
478
|
+
f"[delivery-enforcement] escalate mode: direct edit to '{rel}' allowed "
|
|
479
|
+
f"({'whole-file' if changed is None else changed} chars). Verify with the project's "
|
|
480
|
+
"build/test; deliver is the escalation point after repeated gate failures or for "
|
|
481
|
+
"large multi-file work.",
|
|
482
|
+
file=sys.stderr,
|
|
483
|
+
)
|
|
484
|
+
emit(True, "escalate mode: direct edit allowed; deliver is the escalation point")
|
|
485
|
+
|
|
486
|
+
|
|
286
487
|
BASH_OPS = {"Bash", "bash", "run_bash", "shell"}
|
|
287
488
|
|
|
288
489
|
# The delivery gate's own state. The pending log is the replay queue for
|
|
@@ -295,7 +496,7 @@ BASH_OPS = {"Bash", "bash", "run_bash", "shell"}
|
|
|
295
496
|
# match any destructive verb (or a truncating redirect) against any path under
|
|
296
497
|
# `.uap/` that names deliver state — including the directory itself and globs.
|
|
297
498
|
_DELIVER_STATE_PATH = (
|
|
298
|
-
r"(?:pending-deliver\.jsonl|deliver\.lock|deliver\.heartbeat"
|
|
499
|
+
r"(?:pending-deliver\.jsonl|deliver\.lock|deliver\.heartbeat|escalation-state\.json"
|
|
299
500
|
r"|pending-[^\s'\"|;&]*|deliver\.[^\s'\"|;&]*|\*[^\s'\"|;&]*)"
|
|
300
501
|
)
|
|
301
502
|
_DELIVER_STATE_RM_RE = re.compile(
|
|
@@ -515,9 +716,17 @@ def _handle_bash(args: dict) -> None:
|
|
|
515
716
|
m = _BASH_WRITE_RE.search(cmd)
|
|
516
717
|
if m:
|
|
517
718
|
target = m.group(1)
|
|
518
|
-
mode =
|
|
519
|
-
if mode == "
|
|
520
|
-
|
|
719
|
+
mode = _effective_mode()
|
|
720
|
+
if mode == "escalate":
|
|
721
|
+
# Size is unknowable for a shell write; failures/churn still decide.
|
|
722
|
+
root = repo_root()
|
|
723
|
+
trig = _escalation_trigger(root, target, None, size_known=False)
|
|
724
|
+
if trig is not None:
|
|
725
|
+
_escalation_block(target, trig[0], trig[1], {})
|
|
726
|
+
return
|
|
727
|
+
_record_escalation_edit(root, target)
|
|
728
|
+
emit(True, "escalate mode: shell source write allowed; deliver is the escalation point")
|
|
729
|
+
return
|
|
521
730
|
msg = (
|
|
522
731
|
f"BLOCKED: do not write source via the shell ('{target}'). Writing files with "
|
|
523
732
|
"a redirect/heredoc/sed/tee bypasses the delivery gate. To create or change "
|
|
@@ -604,7 +813,7 @@ def main() -> None:
|
|
|
604
813
|
# testing the same code underneath it — tell it to WAIT. route:wait so the
|
|
605
814
|
# autoroute stands down instead of enqueuing a duplicate.
|
|
606
815
|
holder = _deliver_lock_holder(root)
|
|
607
|
-
if holder is not None and os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower()
|
|
816
|
+
if holder is not None and os.environ.get("UAP_ENFORCE_DELIVERY", "block").lower() in {"block", "escalate"}:
|
|
608
817
|
emit(
|
|
609
818
|
False,
|
|
610
819
|
f"WAIT: a `uap deliver` run (pid {holder}) is ALREADY in progress for this project and is "
|
|
@@ -618,6 +827,15 @@ def main() -> None:
|
|
|
618
827
|
)
|
|
619
828
|
return
|
|
620
829
|
|
|
830
|
+
# ESCALATE: direct edits land unless the evidence (failed gates, churn,
|
|
831
|
+
# size) says this change needs the convergence loop.
|
|
832
|
+
if _effective_mode() == "escalate":
|
|
833
|
+
changed = _changed_chars(args)
|
|
834
|
+
if changed is None:
|
|
835
|
+
changed = _write_cost(args, root)
|
|
836
|
+
_handle_escalate_edit(args, rel_posix, root, changed)
|
|
837
|
+
return
|
|
838
|
+
|
|
621
839
|
# #3-F: terse, imperative, model-parseable. Weak local models otherwise
|
|
622
840
|
# retry the blocked edit or hallucinate completion ("the files exist") when
|
|
623
841
|
# the message is a passive explanation. State the exact next action.
|
|
@@ -649,8 +867,8 @@ def main() -> None:
|
|
|
649
867
|
intent_payload = {
|
|
650
868
|
k: v
|
|
651
869
|
for k, v in (
|
|
652
|
-
("old_string", args.get("old_string")),
|
|
653
|
-
("new_string", args.get("new_string")),
|
|
870
|
+
("old_string", args.get("old_string", args.get("oldString"))),
|
|
871
|
+
("new_string", args.get("new_string", args.get("newString"))),
|
|
654
872
|
("content", args.get("content")),
|
|
655
873
|
)
|
|
656
874
|
if isinstance(v, str)
|
|
Binary file
|
|
@@ -120,8 +120,8 @@ def decide(out: dict, tool: str, args: dict, autoroute_on: bool, seen_files: set
|
|
|
120
120
|
edit_intent = out.get("editIntent") or {
|
|
121
121
|
k: v
|
|
122
122
|
for k, v in (
|
|
123
|
-
("old_string", args.get("old_string")),
|
|
124
|
-
("new_string", args.get("new_string")),
|
|
123
|
+
("old_string", args.get("old_string", args.get("oldString"))),
|
|
124
|
+
("new_string", args.get("new_string", args.get("newString"))),
|
|
125
125
|
("content", args.get("content")),
|
|
126
126
|
)
|
|
127
127
|
if isinstance(v, str)
|