@miller-tech/uap 1.148.2 → 1.148.4
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/dist/.tsbuildinfo +1 -1
- package/dist/cli/deliver.d.ts +10 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +89 -1
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/hooks.d.ts +1 -0
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +35 -1
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +28 -7
- package/dist/cli/verify.js.map +1 -1
- package/dist/cli/worktree.js +2 -2
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +25 -8
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/user-validation.d.ts.map +1 -1
- package/dist/delivery/user-validation.js +13 -1
- package/dist/delivery/user-validation.js.map +1 -1
- package/dist/delivery/vision-judge.d.ts +11 -0
- package/dist/delivery/vision-judge.d.ts.map +1 -1
- package/dist/delivery/vision-judge.js +60 -2
- package/dist/delivery/vision-judge.js.map +1 -1
- package/dist/delivery/visual-gate.d.ts +0 -7
- package/dist/delivery/visual-gate.d.ts.map +1 -1
- package/dist/delivery/visual-gate.js +51 -2
- package/dist/delivery/visual-gate.js.map +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/_common.py +6 -0
- package/src/policies/enforcers/delivery_enforcement.py +93 -4
- package/src/policies/enforcers/enforcement_self_protect.py +21 -4
- package/src/policies/enforcers/expert_review_required.py +13 -4
- package/templates/hooks/uap-policy-gate.sh +74 -6
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +217 -0
- package/tools/agents/tests/test_empty_maxtokens_recovery.py +94 -0
- package/tools/agents/tests/test_error_loop_break.py +88 -0
|
@@ -24,7 +24,9 @@ import sys
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
26
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
27
|
-
from _common import
|
|
27
|
+
from _common import ( # noqa: E402
|
|
28
|
+
emit, parse_cli, worktree_root, run, REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
|
|
29
|
+
)
|
|
28
30
|
|
|
29
31
|
# Ship verbs are anchored to their tool prefix so that the bare tokens "merge"
|
|
30
32
|
# or "signoff" inside read-only commands (git diff --merge-base, rg merge,
|
|
@@ -81,12 +83,12 @@ def _is_low_risk(f: str) -> bool:
|
|
|
81
83
|
def _active_waiver(root: Path) -> bool:
|
|
82
84
|
"""A committable, env-free bypass: an active waiver file. Works in harnesses
|
|
83
85
|
that strip env vars (where UAP_NO_REVIEW=1 cannot be set)."""
|
|
84
|
-
wdir = root /
|
|
86
|
+
wdir = root / REVIEW_WAIVER_DIR
|
|
85
87
|
if wdir.exists():
|
|
86
88
|
for w in wdir.glob("*expert-review*.md"):
|
|
87
89
|
if w.is_file():
|
|
88
90
|
return True
|
|
89
|
-
return (root /
|
|
91
|
+
return (root / REVIEW_ARTIFACT_DIR / "WAIVER").exists()
|
|
90
92
|
|
|
91
93
|
|
|
92
94
|
def current_branch(root: Path) -> str | None:
|
|
@@ -129,6 +131,13 @@ def main() -> None:
|
|
|
129
131
|
emit(True, "not a ship operation")
|
|
130
132
|
|
|
131
133
|
cmd = args.get("command") or args.get("cmd") or ""
|
|
134
|
+
# The policy-gate hook runs in the harness env, not the inline command env,
|
|
135
|
+
# so `UAP_NO_REVIEW=1 git commit ...` never reaches os.environ above. Honor
|
|
136
|
+
# an inline assignment parsed from the command string too.
|
|
137
|
+
# Anchored to a LEADING env-assignment run so an incidental mention in a
|
|
138
|
+
# quoted arg / commit message does NOT silently skip review.
|
|
139
|
+
if re.search(r"^\s*(?:[A-Za-z_]\w*=\S*\s+)*UAP_NO_REVIEW=['\"]?1\b", cmd):
|
|
140
|
+
emit(True, "UAP_NO_REVIEW inline override set")
|
|
132
141
|
if not any(p.search(cmd) for p in SHIP_PATTERNS):
|
|
133
142
|
emit(True, "not a ship action")
|
|
134
143
|
|
|
@@ -159,7 +168,7 @@ def main() -> None:
|
|
|
159
168
|
"— parallel expert review not required",
|
|
160
169
|
)
|
|
161
170
|
|
|
162
|
-
review = root /
|
|
171
|
+
review = root / REVIEW_ARTIFACT_DIR / f"{slug}.json"
|
|
163
172
|
if not review.exists():
|
|
164
173
|
emit(
|
|
165
174
|
False,
|
|
@@ -63,11 +63,37 @@ export UAP_REPO_ROOT="$MAIN_ROOT"
|
|
|
63
63
|
# actual WORKING TREE, not the (possibly bare) MAIN_ROOT. Expose the current checkout
|
|
64
64
|
# so _common.worktree_root() targets the worktree when an op runs from inside one.
|
|
65
65
|
export UAP_WORKTREE_ROOT="$CHECKOUT_ROOT"
|
|
66
|
-
# Delivery enforcement
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
export
|
|
66
|
+
# Delivery enforcement posture. Precedence (highest first):
|
|
67
|
+
# 1. .uap.json `delivery.enforcement` — the project's DECLARED posture is
|
|
68
|
+
# AUTHORITATIVE (deliberate, reproducible IaC), so a stale ambient
|
|
69
|
+
# UAP_ENFORCE_DELIVERY=advisory (leaked into a launching shell/session env,
|
|
70
|
+
# e.g. an opencode session started months ago when an env export was still
|
|
71
|
+
# in .bashrc) can NOT silently downgrade a project configured to `block`
|
|
72
|
+
# and let the agent bypass deliver. `delivery.localMode` is read the same
|
|
73
|
+
# authoritative way and selects how a local-model session resolves block
|
|
74
|
+
# (advisory|deliver|block — see delivery_enforcement.py).
|
|
75
|
+
# 2. ambient UAP_ENFORCE_DELIVERY (operator/CI per-run override) when the
|
|
76
|
+
# project declares nothing.
|
|
77
|
+
# 3. default: block.
|
|
78
|
+
# Escape hatches always apply and are honored by the enforcer BEFORE mode:
|
|
79
|
+
# UAP_DELIVER_ACTIVE=1 (inside a deliver run) / UAP_DELIVER_BYPASS=1 (sanctioned
|
|
80
|
+
# manual edit) — so config-authoritative block never re-routes deliver's own edits.
|
|
81
|
+
_DLV_CFG="$(python3 -c '
|
|
82
|
+
import json, sys
|
|
83
|
+
try:
|
|
84
|
+
d = (json.load(open(sys.argv[1])).get("delivery") or {})
|
|
85
|
+
e = str(d.get("enforcement", "")).lower()
|
|
86
|
+
print("%s|%s" % (e if e in ("block", "advisory", "off") else "", str(d.get("localMode", "")).lower()))
|
|
87
|
+
except Exception:
|
|
88
|
+
print("|")
|
|
89
|
+
' "$MAIN_ROOT/.uap.json" 2>/dev/null || echo "|")"
|
|
90
|
+
_DLV_MODE="${_DLV_CFG%%|*}"; _DLV_LOCAL="${_DLV_CFG##*|}"
|
|
91
|
+
if [[ -n "$_DLV_MODE" ]]; then
|
|
92
|
+
export UAP_ENFORCE_DELIVERY="$_DLV_MODE" # config-authoritative: overrides stale ambient
|
|
93
|
+
else
|
|
94
|
+
export UAP_ENFORCE_DELIVERY="${UAP_ENFORCE_DELIVERY:-block}"
|
|
95
|
+
fi
|
|
96
|
+
[[ -n "$_DLV_LOCAL" ]] && export UAP_DELIVER_LOCAL_MODE="$_DLV_LOCAL"
|
|
71
97
|
cd "$MAIN_ROOT"
|
|
72
98
|
|
|
73
99
|
TOOL="$(printf '%s' "$PAYLOAD" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("tool_name") or d.get("tool") or "")' 2>/dev/null || true)"
|
|
@@ -103,6 +129,41 @@ print("1" if (hit or bypass) else "0")
|
|
|
103
129
|
# Operator out-of-band override disables the fail-closed guard too.
|
|
104
130
|
[[ "${UAP_SELF_PROTECT_OFF:-}" == "1" ]] && SEC_SENSITIVE=0
|
|
105
131
|
|
|
132
|
+
# ── Edit fast-path (A) ───────────────────────────────────────────────────────
|
|
133
|
+
# Test files and TRIVIAL edits (a one-liner, a constant, a typo) are the fast
|
|
134
|
+
# feedback loop; routing each through the full worktree/task/deliver workflow
|
|
135
|
+
# gates costs a ~10+ min deliver cycle on a slow local executor. Allow them
|
|
136
|
+
# directly — BEFORE the workflow-gate loop — so iteration stays fast. This never
|
|
137
|
+
# bypasses SAFETY: a sensitive control-surface path (SEC_SENSITIVE) is excluded,
|
|
138
|
+
# and self/infra-protect gate bash, not these edits. UAP_DELIVER_FASTPATH=off
|
|
139
|
+
# disables; UAP_DELIVER_TRIVIAL_EDIT_CHARS tunes the trivial threshold.
|
|
140
|
+
if [[ "${UAP_DELIVER_FASTPATH:-on}" != "off" && "$SEC_SENSITIVE" != "1" ]]; then
|
|
141
|
+
case " Edit Write MultiEdit edit write multiedit " in
|
|
142
|
+
*" $TOOL "*)
|
|
143
|
+
if printf '%s' "$ARGS" | TRIVIAL="${UAP_DELIVER_TRIVIAL_EDIT_CHARS:-240}" python3 -c '
|
|
144
|
+
import json, os, sys
|
|
145
|
+
try: a = json.loads(sys.stdin.read() or "{}")
|
|
146
|
+
except Exception: sys.exit(1)
|
|
147
|
+
target = (a.get("file_path") or a.get("filePath") or a.get("path") or a.get("target") or a.get("filename") or a.get("file") or "").lower()
|
|
148
|
+
base = target.rsplit("/", 1)[-1]; stem = base.rsplit(".", 1)[0]
|
|
149
|
+
markers = (".test.", ".spec.", "_test.", "test_", "/test/", "/tests/", "/__tests__/", "/spec/", "/specs/")
|
|
150
|
+
is_test = any(m in "/" + target for m in markers) or stem in ("test","tests","spec","specs","conftest") or stem.startswith("test") or stem.endswith("test") or stem.endswith("spec")
|
|
151
|
+
def changed(a):
|
|
152
|
+
if isinstance(a.get("edits"), list):
|
|
153
|
+
return sum(len(str(e.get("old_string") or e.get("oldString") or "")) + len(str(e.get("new_string") or e.get("newString") or "")) for e in a["edits"] if isinstance(e, dict))
|
|
154
|
+
o=a.get("old_string") or a.get("oldString"); n=a.get("new_string") or a.get("newString")
|
|
155
|
+
return (len(str(o or ""))+len(str(n or ""))) if (o is not None or n is not None) else None
|
|
156
|
+
c = changed(a)
|
|
157
|
+
trivial = c is not None and c <= int(os.environ.get("TRIVIAL","240"))
|
|
158
|
+
sys.exit(0 if (is_test or trivial) else 1)
|
|
159
|
+
' 2>/dev/null; then
|
|
160
|
+
echo "[UAP policy gate] fast-path: test-file or trivial edit — allowed directly (route substantive source changes through deliver)." >&2
|
|
161
|
+
exit 0
|
|
162
|
+
fi
|
|
163
|
+
;;
|
|
164
|
+
esac
|
|
165
|
+
fi
|
|
166
|
+
|
|
106
167
|
fail_closed() {
|
|
107
168
|
echo "[UAP policy gate] FAIL-CLOSED: this operation touches the enforcement control surface but the self-protect enforcer could not run (${1:-machinery unavailable}). Blocked so a broken/absent gate can't become a bypass. (Operator override: UAP_SELF_PROTECT_OFF=1.)" >&2
|
|
108
169
|
exit 2
|
|
@@ -182,7 +243,14 @@ except: print("")' 2>/dev/null || echo "")"
|
|
|
182
243
|
echo "$msg" >&2
|
|
183
244
|
exit 2
|
|
184
245
|
fi
|
|
185
|
-
|
|
246
|
+
# ORDER BY priority DESC: the policies table has a `priority` column but the
|
|
247
|
+
# gate previously ignored it, iterating in rowid order — so a high-priority
|
|
248
|
+
# routing/safety policy (e.g. delivery-enforcement, infra-protect) could be
|
|
249
|
+
# pre-empted by a lower-priority edit-gate that happened to be registered
|
|
250
|
+
# first, and the agent saw the wrong routing message. Honor priority now so
|
|
251
|
+
# the intended policy fires first. (Blocking short-circuits at exit 2, so the
|
|
252
|
+
# highest-priority applicable block wins.) Name is the stable tiebreak.
|
|
253
|
+
done < <(sqlite3 "$DB" "SELECT p.id, p.name, t.toolName FROM policies p JOIN executable_tools t ON t.policyId=p.id WHERE p.isActive=1 ORDER BY p.priority DESC, p.name ASC;")
|
|
186
254
|
|
|
187
255
|
# A sensitive op that no self-protect enforcer ever evaluated = the control
|
|
188
256
|
# surface is unguarded (self-protect not registered/active). Fail closed.
|
|
Binary file
|
|
@@ -274,6 +274,49 @@ _RATE_LIMITED_API_RE = re.compile(r"api\.github\.com", re.IGNORECASE)
|
|
|
274
274
|
PROXY_STUCK_TEXT_THRESHOLD = int(os.environ.get("PROXY_STUCK_TEXT_THRESHOLD", "2"))
|
|
275
275
|
PROXY_STUCK_API_THRESHOLD = int(os.environ.get("PROXY_STUCK_API_THRESHOLD", "3"))
|
|
276
276
|
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# ERROR-LOOP guardrail: the model edits, runs a command, hits the SAME failure,
|
|
279
|
+
# edits again (a DIFFERENT edit), runs, hits the same failure — for many turns.
|
|
280
|
+
# The identical-tool-call loop/cycle detectors never trip because each edit
|
|
281
|
+
# differs; the model looks productive while never addressing the real blocker
|
|
282
|
+
# (observed live: a duplicate function declaration in test.js kept the test from
|
|
283
|
+
# compiling, and the model chased an unrelated error in another file for 35 min).
|
|
284
|
+
# Detect a repeated tool_RESULT error SIGNATURE (normalized: paths/line numbers/
|
|
285
|
+
# digits/hex stripped so it's edit-invariant) and, past the threshold, inject a
|
|
286
|
+
# nudge to STOP editing and re-read the failing file/output end-to-end.
|
|
287
|
+
# PROXY_ERROR_LOOP=off to disable.
|
|
288
|
+
PROXY_ERROR_LOOP = os.environ.get("PROXY_ERROR_LOOP", "on").lower() not in {
|
|
289
|
+
"0", "off", "false", "no",
|
|
290
|
+
}
|
|
291
|
+
PROXY_ERROR_LOOP_THRESHOLD = int(os.environ.get("PROXY_ERROR_LOOP_THRESHOLD", "3"))
|
|
292
|
+
|
|
293
|
+
_ERROR_LINE_RE = re.compile(
|
|
294
|
+
r"^.*(?:Error|Exception|Traceback|FAILED|assert(?:ion)?|SyntaxError|"
|
|
295
|
+
r"TypeError|ReferenceError|not found|cannot find|undefined|panic|"
|
|
296
|
+
r"fatal|command not found|exit code [1-9]).*$",
|
|
297
|
+
re.IGNORECASE | re.MULTILINE,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _error_signature(text: str) -> str:
|
|
302
|
+
"""Edit-invariant signature of the first error line in a tool result.
|
|
303
|
+
|
|
304
|
+
Normalizes away paths, line:col numbers, hex, and bare digits so that the
|
|
305
|
+
SAME underlying failure produces the SAME signature across turns even as the
|
|
306
|
+
model edits different code around it. Returns "" when no error line is found
|
|
307
|
+
(a passing result resets the streak)."""
|
|
308
|
+
if not text:
|
|
309
|
+
return ""
|
|
310
|
+
m = _ERROR_LINE_RE.search(text)
|
|
311
|
+
if not m:
|
|
312
|
+
return ""
|
|
313
|
+
line = m.group(0)
|
|
314
|
+
line = re.sub(r"(/[^\s:]+)+", "<path>", line) # unix paths
|
|
315
|
+
line = re.sub(r"\b[0-9a-fA-F]{6,}\b", "<hex>", line) # hashes/addresses
|
|
316
|
+
line = re.sub(r"\d+", "#", line) # line numbers, counts
|
|
317
|
+
line = re.sub(r"\s+", " ", line).strip().lower()
|
|
318
|
+
return line[:200]
|
|
319
|
+
|
|
277
320
|
# ---------------------------------------------------------------------------
|
|
278
321
|
# DEFERRAL-BREAK guardrail (Fix A): a model can end a turn with plain prose that
|
|
279
322
|
# DEFERS the work instead of doing it -- "I need more exploration cycles to
|
|
@@ -769,6 +812,19 @@ PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS = int(
|
|
|
769
812
|
PROXY_MALFORMED_TOOL_RETRY_TEMPERATURE = float(
|
|
770
813
|
os.environ.get("PROXY_MALFORMED_TOOL_RETRY_TEMPERATURE", "0")
|
|
771
814
|
)
|
|
815
|
+
# Empty-max_tokens recovery (Option 3): a reasoning model can consume the ENTIRE
|
|
816
|
+
# output budget in reasoning_content and emit NO content/tool_calls
|
|
817
|
+
# (finish_reason=length, empty message) — a thinking-runaway. The upstream
|
|
818
|
+
# --reasoning-budget cap is the primary fix; this is the backstop: on detecting
|
|
819
|
+
# such an empty truncation, retry ONCE with thinking disabled so the turn yields
|
|
820
|
+
# a usable answer instead of an empty response the client blindly re-sends
|
|
821
|
+
# (observed: 529 retry cascades). PROXY_EMPTY_MAXTOKENS_RECOVER=off to disable.
|
|
822
|
+
PROXY_EMPTY_MAXTOKENS_RECOVER = os.environ.get(
|
|
823
|
+
"PROXY_EMPTY_MAXTOKENS_RECOVER", "on"
|
|
824
|
+
).lower() not in {"0", "off", "false", "no"}
|
|
825
|
+
PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS = int(
|
|
826
|
+
os.environ.get("PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS", "4096")
|
|
827
|
+
)
|
|
772
828
|
PROXY_TOOL_TURN_TEMPERATURE = float(
|
|
773
829
|
os.environ.get("PROXY_TOOL_TURN_TEMPERATURE", "0.3")
|
|
774
830
|
)
|
|
@@ -1358,6 +1414,10 @@ class SessionMonitor:
|
|
|
1358
1414
|
catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
|
|
1359
1415
|
unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
|
|
1360
1416
|
self_stuck_streak: int = 0 # consecutive assistant texts self-reporting a loop
|
|
1417
|
+
error_signature_streak: int = 0 # consecutive turns with the SAME tool_result error
|
|
1418
|
+
last_error_signature: str = "" # normalized signature of that recurring error
|
|
1419
|
+
error_loop_fires: int = 0 # telemetry: error-loop nudges injected
|
|
1420
|
+
empty_maxtokens_recoveries: int = 0 # telemetry: thinking-runaway recoveries
|
|
1361
1421
|
rate_limited_api_streak: int = 0 # consecutive tool calls hitting a rate-limited API host
|
|
1362
1422
|
stuck_break_fires: int = 0 # monotonic count of forced stuck-breaks
|
|
1363
1423
|
deferral_streak: int = 0 # consecutive no-tool turns deferring the work (Fix A)
|
|
@@ -1599,6 +1659,24 @@ class SessionMonitor:
|
|
|
1599
1659
|
by_tool = self.tool_target_history.setdefault(name, {})
|
|
1600
1660
|
by_tool[target] = by_tool.get(target, 0) + 1
|
|
1601
1661
|
|
|
1662
|
+
def note_tool_result_error(self, latest_result_text: str) -> None:
|
|
1663
|
+
"""Track a repeated tool_result error signature (ERROR-LOOP guardrail).
|
|
1664
|
+
|
|
1665
|
+
Same normalized error as last turn -> increment the streak; a new error
|
|
1666
|
+
or a clean (error-free) result -> reset. Only a SUSTAINED same-failure
|
|
1667
|
+
streak (despite the model's varied edits) trips the nudge."""
|
|
1668
|
+
if not PROXY_ERROR_LOOP:
|
|
1669
|
+
return
|
|
1670
|
+
sig = _error_signature(latest_result_text or "")
|
|
1671
|
+
if sig and sig == self.last_error_signature:
|
|
1672
|
+
self.error_signature_streak += 1
|
|
1673
|
+
elif sig:
|
|
1674
|
+
self.error_signature_streak = 1
|
|
1675
|
+
self.last_error_signature = sig
|
|
1676
|
+
else:
|
|
1677
|
+
self.error_signature_streak = 0
|
|
1678
|
+
self.last_error_signature = ""
|
|
1679
|
+
|
|
1602
1680
|
def note_assistant_text(self, text: str) -> None:
|
|
1603
1681
|
"""Track the model self-reporting that it is stuck (STUCK-BREAK signal
|
|
1604
1682
|
(a)). A matching turn increments the streak; a non-matching turn resets
|
|
@@ -4905,6 +4983,46 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
|
|
|
4905
4983
|
logger.warning("STUCK-BREAK: forced terminal turn (%s, fires=%d)", reason, monitor.stuck_break_fires)
|
|
4906
4984
|
|
|
4907
4985
|
|
|
4986
|
+
def _maybe_inject_error_loop_break(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
4987
|
+
"""Nudge the model out of a repeated-same-error loop (ERROR-LOOP guardrail).
|
|
4988
|
+
|
|
4989
|
+
When the SAME normalized tool_result error has recurred >= threshold times
|
|
4990
|
+
despite the model's (varied) edits, its edits aren't addressing the real
|
|
4991
|
+
blocker. Inject a directive to STOP editing and re-read the whole failing
|
|
4992
|
+
file/output — the bug is likely somewhere it hasn't looked. Advisory: does
|
|
4993
|
+
NOT release tool_choice (the model should keep acting, just diagnose first)."""
|
|
4994
|
+
if not PROXY_ERROR_LOOP:
|
|
4995
|
+
return
|
|
4996
|
+
if monitor.error_signature_streak < PROXY_ERROR_LOOP_THRESHOLD:
|
|
4997
|
+
return
|
|
4998
|
+
monitor.error_loop_fires += 1
|
|
4999
|
+
directive = (
|
|
5000
|
+
"\n\nSTOP — the SAME failure has now recurred "
|
|
5001
|
+
+ str(monitor.error_signature_streak)
|
|
5002
|
+
+ " times in a row despite your edits: \""
|
|
5003
|
+
+ monitor.last_error_signature[:120]
|
|
5004
|
+
+ "\". Your edits are NOT addressing the real cause. Do NOT make another "
|
|
5005
|
+
"edit yet. FIRST re-read the ENTIRE failing file (and the full error "
|
|
5006
|
+
"output) top-to-bottom — the bug is very likely somewhere you have not "
|
|
5007
|
+
"looked (a duplicate declaration, a wrong import, a different file). "
|
|
5008
|
+
"Only after you can name the exact line causing THIS error, fix that."
|
|
5009
|
+
)
|
|
5010
|
+
msgs = openai_body.get("messages")
|
|
5011
|
+
if not isinstance(msgs, list):
|
|
5012
|
+
msgs = []
|
|
5013
|
+
if msgs and msgs[0].get("role") == "system":
|
|
5014
|
+
msgs[0]["content"] = (msgs[0].get("content") or "") + directive
|
|
5015
|
+
else:
|
|
5016
|
+
msgs.insert(0, {"role": "system", "content": directive.strip()})
|
|
5017
|
+
openai_body["messages"] = msgs
|
|
5018
|
+
logger.warning(
|
|
5019
|
+
"ERROR-LOOP: same failure x%d — injected re-read nudge (sig=%r, fires=%d)",
|
|
5020
|
+
monitor.error_signature_streak,
|
|
5021
|
+
monitor.last_error_signature[:80],
|
|
5022
|
+
monitor.error_loop_fires,
|
|
5023
|
+
)
|
|
5024
|
+
|
|
5025
|
+
|
|
4908
5026
|
def _maybe_inject_deferral_break(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
4909
5027
|
"""Convert a turn-ending deferral into forward motion (Fix A).
|
|
4910
5028
|
|
|
@@ -5897,6 +6015,9 @@ def build_openai_request(
|
|
|
5897
6015
|
|
|
5898
6016
|
_maybe_inject_stuck_break(openai_body, monitor)
|
|
5899
6017
|
|
|
6018
|
+
# ERROR-LOOP: same tool_result failure recurring despite varied edits.
|
|
6019
|
+
_maybe_inject_error_loop_break(openai_body, monitor)
|
|
6020
|
+
|
|
5900
6021
|
# DEFERRAL-BREAK (Fix A): after stuck-break, drive a no-tool deferral turn
|
|
5901
6022
|
# into a concrete action. Runs last so it can yield to stuck-break.
|
|
5902
6023
|
_maybe_inject_deferral_break(openai_body, monitor)
|
|
@@ -6030,6 +6151,21 @@ def _record_last_assistant_tool_calls(
|
|
|
6030
6151
|
with different arguments (e.g. read(file_a) vs read(file_b)) produces
|
|
6031
6152
|
distinct fingerprints, preventing false cycle/stagnation detection."""
|
|
6032
6153
|
messages = anthropic_body.get("messages", [])
|
|
6154
|
+
# ERROR-LOOP tracking: feed the monitor the most recent tool_result text so a
|
|
6155
|
+
# recurring same-failure signature can be detected across (varied) edits.
|
|
6156
|
+
_latest_tr = ""
|
|
6157
|
+
for _m in reversed(messages):
|
|
6158
|
+
_c = _m.get("content")
|
|
6159
|
+
if isinstance(_c, list):
|
|
6160
|
+
_parts = [
|
|
6161
|
+
_extract_text(b.get("content", ""))
|
|
6162
|
+
for b in _c
|
|
6163
|
+
if isinstance(b, dict) and b.get("type") == "tool_result"
|
|
6164
|
+
]
|
|
6165
|
+
if _parts:
|
|
6166
|
+
_latest_tr = "\n".join(p for p in _parts if p)
|
|
6167
|
+
break
|
|
6168
|
+
monitor.note_tool_result_error(_latest_tr)
|
|
6033
6169
|
tool_fingerprints = []
|
|
6034
6170
|
tool_targets: dict[str, str] = {}
|
|
6035
6171
|
assistant_had_text = False # Fix B: did the last assistant turn emit prose?
|
|
@@ -8159,6 +8295,71 @@ async def _apply_completion_contract_guardrail(
|
|
|
8159
8295
|
return retried
|
|
8160
8296
|
|
|
8161
8297
|
|
|
8298
|
+
def _is_empty_maxtokens_response(openai_resp: dict) -> bool:
|
|
8299
|
+
"""A finish_reason=length turn that produced NO content and NO tool calls —
|
|
8300
|
+
the thinking-runaway signature (all budget spent in reasoning_content)."""
|
|
8301
|
+
choices = openai_resp.get("choices") or []
|
|
8302
|
+
if not choices:
|
|
8303
|
+
return False
|
|
8304
|
+
choice = choices[0]
|
|
8305
|
+
if (choice.get("finish_reason") or "").lower() != "length":
|
|
8306
|
+
return False
|
|
8307
|
+
msg = choice.get("message") or {}
|
|
8308
|
+
if msg.get("tool_calls"):
|
|
8309
|
+
return False
|
|
8310
|
+
content = msg.get("content")
|
|
8311
|
+
text = content if isinstance(content, str) else ""
|
|
8312
|
+
return len(text.strip()) == 0
|
|
8313
|
+
|
|
8314
|
+
|
|
8315
|
+
async def _apply_empty_maxtokens_recovery(
|
|
8316
|
+
client: httpx.AsyncClient,
|
|
8317
|
+
openai_resp: dict,
|
|
8318
|
+
openai_body: dict,
|
|
8319
|
+
anthropic_body: dict,
|
|
8320
|
+
monitor: SessionMonitor,
|
|
8321
|
+
session_id: str,
|
|
8322
|
+
) -> dict:
|
|
8323
|
+
"""Option 3 backstop: recover an empty finish=length turn by retrying once
|
|
8324
|
+
with thinking OFF, so a residual reasoning-runaway yields a usable answer
|
|
8325
|
+
instead of an empty response the client blindly re-sends (529 cascade)."""
|
|
8326
|
+
if not PROXY_EMPTY_MAXTOKENS_RECOVER:
|
|
8327
|
+
return openai_resp
|
|
8328
|
+
if not _is_empty_maxtokens_response(openai_resp):
|
|
8329
|
+
return openai_resp
|
|
8330
|
+
retry_body = dict(openai_body)
|
|
8331
|
+
retry_body["enable_thinking"] = False
|
|
8332
|
+
ctk = dict(retry_body.get("chat_template_kwargs") or {})
|
|
8333
|
+
ctk["enable_thinking"] = False
|
|
8334
|
+
retry_body["chat_template_kwargs"] = ctk
|
|
8335
|
+
requested = int(openai_body.get("max_tokens") or PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS)
|
|
8336
|
+
retry_body["max_tokens"] = min(requested, PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS)
|
|
8337
|
+
logger.warning(
|
|
8338
|
+
"EMPTY-MAX_TOKENS recovery: thinking-runaway (empty finish=length) for "
|
|
8339
|
+
"session %s — retrying once with thinking OFF (max_tokens=%d)",
|
|
8340
|
+
session_id, retry_body["max_tokens"],
|
|
8341
|
+
)
|
|
8342
|
+
try:
|
|
8343
|
+
retry_resp = await client.post(
|
|
8344
|
+
f"{LLAMA_CPP_BASE}/chat/completions",
|
|
8345
|
+
json=retry_body,
|
|
8346
|
+
headers={"Content-Type": "application/json"},
|
|
8347
|
+
)
|
|
8348
|
+
if retry_resp.status_code != 200:
|
|
8349
|
+
return openai_resp
|
|
8350
|
+
retried = retry_resp.json()
|
|
8351
|
+
if not _is_empty_maxtokens_response(retried):
|
|
8352
|
+
monitor.empty_maxtokens_recoveries += 1
|
|
8353
|
+
logger.info(
|
|
8354
|
+
"EMPTY-MAX_TOKENS recovery succeeded for session %s (recoveries=%d)",
|
|
8355
|
+
session_id, monitor.empty_maxtokens_recoveries,
|
|
8356
|
+
)
|
|
8357
|
+
return retried
|
|
8358
|
+
except Exception as exc: # noqa: BLE001 — recovery is best-effort
|
|
8359
|
+
logger.warning("EMPTY-MAX_TOKENS recovery errored for session %s: %s", session_id, exc)
|
|
8360
|
+
return openai_resp
|
|
8361
|
+
|
|
8362
|
+
|
|
8162
8363
|
def _sanitize_assistant_messages_for_retry(messages: list[dict]) -> list[dict]:
|
|
8163
8364
|
"""Strip malformed tool-like text from assistant messages to prevent copy-contamination.
|
|
8164
8365
|
|
|
@@ -10672,6 +10873,14 @@ async def messages(request: Request):
|
|
|
10672
10873
|
monitor,
|
|
10673
10874
|
session_id,
|
|
10674
10875
|
)
|
|
10876
|
+
openai_resp = await _apply_empty_maxtokens_recovery(
|
|
10877
|
+
client,
|
|
10878
|
+
openai_resp,
|
|
10879
|
+
strict_body,
|
|
10880
|
+
body,
|
|
10881
|
+
monitor,
|
|
10882
|
+
session_id,
|
|
10883
|
+
)
|
|
10675
10884
|
|
|
10676
10885
|
openai_resp, was_degenerate = _detect_and_truncate_degenerate_repetition(openai_resp)
|
|
10677
10886
|
if was_degenerate:
|
|
@@ -11116,6 +11325,14 @@ async def messages(request: Request):
|
|
|
11116
11325
|
monitor,
|
|
11117
11326
|
session_id,
|
|
11118
11327
|
)
|
|
11328
|
+
openai_resp = await _apply_empty_maxtokens_recovery(
|
|
11329
|
+
client,
|
|
11330
|
+
openai_resp,
|
|
11331
|
+
openai_body,
|
|
11332
|
+
body,
|
|
11333
|
+
monitor,
|
|
11334
|
+
session_id,
|
|
11335
|
+
)
|
|
11119
11336
|
|
|
11120
11337
|
choice, _ = _extract_openai_choice(openai_resp)
|
|
11121
11338
|
finish_reason = choice.get("finish_reason", "")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Empty-max_tokens recovery: a thinking-runaway (finish=length, empty content,
|
|
3
|
+
no tool calls) is retried once with thinking OFF so the turn yields an answer
|
|
4
|
+
instead of an empty response the client blindly re-sends."""
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.util
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_proxy():
|
|
12
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
13
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
|
|
14
|
+
m = importlib.util.module_from_spec(spec)
|
|
15
|
+
spec.loader.exec_module(m)
|
|
16
|
+
return m
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
proxy = _load_proxy()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def empty_length_resp():
|
|
23
|
+
return {"choices": [{"finish_reason": "length", "message": {"role": "assistant", "content": ""}}]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def good_resp(text="here is the answer"):
|
|
27
|
+
return {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": text}}]}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DetectTests(unittest.TestCase):
|
|
31
|
+
def test_detects_empty_length(self):
|
|
32
|
+
self.assertTrue(proxy._is_empty_maxtokens_response(empty_length_resp()))
|
|
33
|
+
|
|
34
|
+
def test_ignores_length_with_content(self):
|
|
35
|
+
self.assertFalse(proxy._is_empty_maxtokens_response(
|
|
36
|
+
{"choices": [{"finish_reason": "length", "message": {"content": "partial answer"}}]}))
|
|
37
|
+
|
|
38
|
+
def test_ignores_length_with_tool_calls(self):
|
|
39
|
+
self.assertFalse(proxy._is_empty_maxtokens_response(
|
|
40
|
+
{"choices": [{"finish_reason": "length", "message": {"content": "", "tool_calls": [{"id": "1"}]}}]}))
|
|
41
|
+
|
|
42
|
+
def test_ignores_normal_stop(self):
|
|
43
|
+
self.assertFalse(proxy._is_empty_maxtokens_response(good_resp()))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FakeResp:
|
|
47
|
+
def __init__(self, body):
|
|
48
|
+
self.status_code = 200
|
|
49
|
+
self._body = body
|
|
50
|
+
def json(self):
|
|
51
|
+
return self._body
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FakeClient:
|
|
55
|
+
def __init__(self, body):
|
|
56
|
+
self._body = body
|
|
57
|
+
self.last_json = None
|
|
58
|
+
async def post(self, url, json=None, headers=None): # noqa: A002
|
|
59
|
+
self.last_json = json
|
|
60
|
+
return FakeResp(self._body)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RecoveryTests(unittest.TestCase):
|
|
64
|
+
def setUp(self):
|
|
65
|
+
self.m = proxy.SessionMonitor()
|
|
66
|
+
|
|
67
|
+
def _run(self, resp, client_body, body_in=None):
|
|
68
|
+
client = FakeClient(client_body)
|
|
69
|
+
out = asyncio.run(proxy._apply_empty_maxtokens_recovery(
|
|
70
|
+
client, resp, body_in or {"max_tokens": 16384}, {}, self.m, "t"))
|
|
71
|
+
return out, client
|
|
72
|
+
|
|
73
|
+
def test_recovers_with_thinking_off(self):
|
|
74
|
+
out, client = self._run(empty_length_resp(), good_resp("recovered!"))
|
|
75
|
+
self.assertEqual(out["choices"][0]["message"]["content"], "recovered!")
|
|
76
|
+
# retry request had thinking disabled + a bounded max_tokens
|
|
77
|
+
self.assertIs(client.last_json["chat_template_kwargs"]["enable_thinking"], False)
|
|
78
|
+
self.assertIs(client.last_json["enable_thinking"], False)
|
|
79
|
+
self.assertLessEqual(client.last_json["max_tokens"], proxy.PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS)
|
|
80
|
+
self.assertEqual(self.m.empty_maxtokens_recoveries, 1)
|
|
81
|
+
|
|
82
|
+
def test_no_retry_on_a_healthy_response(self):
|
|
83
|
+
out, client = self._run(good_resp("fine"), good_resp("should-not-be-used"))
|
|
84
|
+
self.assertEqual(out["choices"][0]["message"]["content"], "fine")
|
|
85
|
+
self.assertIsNone(client.last_json) # never called upstream
|
|
86
|
+
|
|
87
|
+
def test_keeps_original_if_retry_also_empty(self):
|
|
88
|
+
out, _ = self._run(empty_length_resp(), empty_length_resp())
|
|
89
|
+
self.assertTrue(proxy._is_empty_maxtokens_response(out)) # unchanged
|
|
90
|
+
self.assertEqual(self.m.empty_maxtokens_recoveries, 0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
unittest.main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ERROR-LOOP guardrail: detect a repeated tool_result error signature across
|
|
3
|
+
varied edits and nudge the model to re-read the failing file.
|
|
4
|
+
|
|
5
|
+
The identical-tool-call loop detectors miss this because each edit differs; this
|
|
6
|
+
keys on the normalized ERROR, which is edit-invariant.
|
|
7
|
+
"""
|
|
8
|
+
import importlib.util
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_proxy_module():
|
|
14
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
15
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
16
|
+
mod = importlib.util.module_from_spec(spec)
|
|
17
|
+
spec.loader.exec_module(mod)
|
|
18
|
+
return mod
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
proxy = _load_proxy_module()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ErrorSignatureTests(unittest.TestCase):
|
|
25
|
+
def test_same_error_different_paths_lines_shares_signature(self):
|
|
26
|
+
a = proxy._error_signature("SyntaxError: Identifier 'testPageLoads' has already been declared\n at /home/x/test.js:75:1")
|
|
27
|
+
b = proxy._error_signature("SyntaxError: Identifier 'testPageLoads' has already been declared\n at /other/path/test.js:128:9")
|
|
28
|
+
self.assertTrue(a)
|
|
29
|
+
self.assertEqual(a, b) # edit-invariant
|
|
30
|
+
|
|
31
|
+
def test_distinct_errors_differ(self):
|
|
32
|
+
a = proxy._error_signature("TypeError: cannot read property 'x' of undefined")
|
|
33
|
+
b = proxy._error_signature("SyntaxError: unexpected token")
|
|
34
|
+
self.assertNotEqual(a, b)
|
|
35
|
+
|
|
36
|
+
def test_clean_output_has_no_signature(self):
|
|
37
|
+
self.assertEqual(proxy._error_signature("ok\nall 12 tests passed"), "")
|
|
38
|
+
self.assertEqual(proxy._error_signature(""), "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MonitorStreakTests(unittest.TestCase):
|
|
42
|
+
def setUp(self):
|
|
43
|
+
self.m = proxy.SessionMonitor()
|
|
44
|
+
|
|
45
|
+
def test_streak_accumulates_on_repeat_and_resets_on_pass(self):
|
|
46
|
+
err = "TypeError: cannot find module 'foo'\n at a.js:1"
|
|
47
|
+
for _ in range(3):
|
|
48
|
+
self.m.note_tool_result_error(err)
|
|
49
|
+
self.assertGreaterEqual(self.m.error_signature_streak, 3)
|
|
50
|
+
# a passing result resets
|
|
51
|
+
self.m.note_tool_result_error("ok, 5 passed")
|
|
52
|
+
self.assertEqual(self.m.error_signature_streak, 0)
|
|
53
|
+
|
|
54
|
+
def test_new_error_resets_streak_to_one(self):
|
|
55
|
+
self.m.note_tool_result_error("Error: A\n at a:1")
|
|
56
|
+
self.m.note_tool_result_error("Error: A\n at a:2")
|
|
57
|
+
self.assertEqual(self.m.error_signature_streak, 2)
|
|
58
|
+
self.m.note_tool_result_error("Error: B different\n at b:1")
|
|
59
|
+
self.assertEqual(self.m.error_signature_streak, 1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InjectionTests(unittest.TestCase):
|
|
63
|
+
def setUp(self):
|
|
64
|
+
self.m = proxy.SessionMonitor()
|
|
65
|
+
|
|
66
|
+
def test_nudge_injected_past_threshold(self):
|
|
67
|
+
for _ in range(proxy.PROXY_ERROR_LOOP_THRESHOLD):
|
|
68
|
+
self.m.note_tool_result_error("SyntaxError: already declared\n at x:1")
|
|
69
|
+
body = {"messages": [{"role": "user", "content": "go"}], "tool_choice": "required"}
|
|
70
|
+
proxy._maybe_inject_error_loop_break(body, self.m)
|
|
71
|
+
sysmsg = body["messages"][0]
|
|
72
|
+
self.assertEqual(sysmsg["role"], "system")
|
|
73
|
+
self.assertIn("SAME failure", sysmsg["content"])
|
|
74
|
+
self.assertIn("re-read", sysmsg["content"].lower())
|
|
75
|
+
# advisory: does NOT release tool_choice
|
|
76
|
+
self.assertEqual(body["tool_choice"], "required")
|
|
77
|
+
self.assertEqual(self.m.error_loop_fires, 1)
|
|
78
|
+
|
|
79
|
+
def test_no_nudge_below_threshold(self):
|
|
80
|
+
self.m.note_tool_result_error("Error: once\n at x:1")
|
|
81
|
+
body = {"messages": [{"role": "user", "content": "go"}]}
|
|
82
|
+
proxy._maybe_inject_error_loop_break(body, self.m)
|
|
83
|
+
self.assertEqual(len(body["messages"]), 1) # unchanged
|
|
84
|
+
self.assertEqual(self.m.error_loop_fires, 0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
unittest.main()
|