@miller-tech/uap 1.148.2 → 1.148.5
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 +74 -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 +100 -4
- package/src/policies/enforcers/enforcement_self_protect.py +21 -4
- package/src/policies/enforcers/expert_review_required.py +13 -4
- package/templates/hooks/fastpath_gate.py +123 -0
- package/templates/hooks/uap-policy-gate.sh +98 -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
|
@@ -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()
|