@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.
Files changed (38) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cli/deliver.d.ts +10 -0
  3. package/dist/cli/deliver.d.ts.map +1 -1
  4. package/dist/cli/deliver.js +89 -1
  5. package/dist/cli/deliver.js.map +1 -1
  6. package/dist/cli/hooks.d.ts +1 -0
  7. package/dist/cli/hooks.d.ts.map +1 -1
  8. package/dist/cli/hooks.js +35 -1
  9. package/dist/cli/hooks.js.map +1 -1
  10. package/dist/cli/verify.d.ts.map +1 -1
  11. package/dist/cli/verify.js +28 -7
  12. package/dist/cli/verify.js.map +1 -1
  13. package/dist/cli/worktree.js +2 -2
  14. package/dist/delivery/agentic-executor.d.ts.map +1 -1
  15. package/dist/delivery/agentic-executor.js +25 -8
  16. package/dist/delivery/agentic-executor.js.map +1 -1
  17. package/dist/delivery/user-validation.d.ts.map +1 -1
  18. package/dist/delivery/user-validation.js +13 -1
  19. package/dist/delivery/user-validation.js.map +1 -1
  20. package/dist/delivery/vision-judge.d.ts +11 -0
  21. package/dist/delivery/vision-judge.d.ts.map +1 -1
  22. package/dist/delivery/vision-judge.js +60 -2
  23. package/dist/delivery/vision-judge.js.map +1 -1
  24. package/dist/delivery/visual-gate.d.ts +0 -7
  25. package/dist/delivery/visual-gate.d.ts.map +1 -1
  26. package/dist/delivery/visual-gate.js +51 -2
  27. package/dist/delivery/visual-gate.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  30. package/src/policies/enforcers/_common.py +6 -0
  31. package/src/policies/enforcers/delivery_enforcement.py +93 -4
  32. package/src/policies/enforcers/enforcement_self_protect.py +21 -4
  33. package/src/policies/enforcers/expert_review_required.py +13 -4
  34. package/templates/hooks/uap-policy-gate.sh +74 -6
  35. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  36. package/tools/agents/scripts/anthropic_proxy.py +217 -0
  37. package/tools/agents/tests/test_empty_maxtokens_recovery.py +94 -0
  38. 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 emit, parse_cli, worktree_root, run # noqa: E402
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 / "policies" / "waivers"
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 / ".uap" / "reviews" / "WAIVER").exists()
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 / ".uap" / "reviews" / f"{slug}.json"
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 defaults to BLOCK for UAP-managed projects: substantive
67
- # source edits must route through `uap deliver` (verified completion against the
68
- # gates). The `:-` preserves any explicit operator/CI override (advisory|block).
69
- # Escape hatches still apply: UAP_DELIVER_ACTIVE=1 (inside deliver) / UAP_DELIVER_BYPASS=1.
70
- export UAP_ENFORCE_DELIVERY="${UAP_ENFORCE_DELIVERY:-block}"
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
- 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;")
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.
@@ -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()