@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.
Files changed (39) 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 +74 -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 +100 -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/fastpath_gate.py +123 -0
  35. package/templates/hooks/uap-policy-gate.sh +98 -6
  36. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  37. package/tools/agents/scripts/anthropic_proxy.py +217 -0
  38. package/tools/agents/tests/test_empty_maxtokens_recovery.py +94 -0
  39. 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()