@miller-tech/uap 1.159.0 → 1.160.1
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/fidelity.d.ts.map +1 -1
- package/dist/cli/fidelity.js +15 -0
- package/dist/cli/fidelity.js.map +1 -1
- package/dist/cli/policy-select.d.ts +10 -0
- package/dist/cli/policy-select.d.ts.map +1 -1
- package/dist/cli/policy-select.js +30 -0
- package/dist/cli/policy-select.js.map +1 -1
- package/dist/cli/policy.d.ts.map +1 -1
- package/dist/cli/policy.js +20 -1
- package/dist/cli/policy.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +20 -0
- package/dist/cli/setup.js.map +1 -1
- package/dist/delivery/epic-controller.d.ts +9 -0
- package/dist/delivery/epic-controller.d.ts.map +1 -1
- package/dist/delivery/epic-controller.js +5 -1
- package/dist/delivery/epic-controller.js.map +1 -1
- package/dist/delivery/user-paths.d.ts.map +1 -1
- package/dist/delivery/user-paths.js +5 -0
- package/dist/delivery/user-paths.js.map +1 -1
- package/dist/delivery/user-validation.d.ts.map +1 -1
- package/dist/delivery/user-validation.js +8 -2
- package/dist/delivery/user-validation.js.map +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/commitment_reserve.py +341 -0
- package/src/policies/schemas/policies/commitment-reserve.md +67 -0
- package/src/policies/schemas/policies/proportional-commitment.md +39 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +158 -5
- package/tools/agents/tests/test_doubling_break.py +239 -0
|
@@ -359,6 +359,31 @@ _DEFERRAL_PHRASE_RE = re.compile(
|
|
|
359
359
|
# turn IS the halt, unlike STUCK-BREAK which waits for a sustained loop.
|
|
360
360
|
PROXY_DEFERRAL_THRESHOLD = int(os.environ.get("PROXY_DEFERRAL_THRESHOLD", "1"))
|
|
361
361
|
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# DOUBLING-DOWN guardrail ("never go full"): the model re-issues the SAME tool
|
|
364
|
+
# call -- identical fingerprint, name + argument hash -- and it keeps FAILING.
|
|
365
|
+
# ERROR-LOOP covers the inverse shape (varied edits, same error signature); the
|
|
366
|
+
# identical-call cycle detector needs 6 repeats regardless of outcome and
|
|
367
|
+
# ignores results entirely. Neither fires on the classic doubling-down run: the
|
|
368
|
+
# same command retried harder while its error text VARIES turn to turn (rate
|
|
369
|
+
# limits, timeouts, flaky tests) -- which is how a session goes all-in on one
|
|
370
|
+
# failing approach (observed live: a rate-limited GitHub API hammered in a
|
|
371
|
+
# loop). After PROXY_DOUBLING_THRESHOLD consecutive failed retries of the SAME
|
|
372
|
+
# call, inject a pivot directive: stop retrying, state a different approach,
|
|
373
|
+
# take it. PROXY_DOUBLING_BREAK=off to disable.
|
|
374
|
+
PROXY_DOUBLING_BREAK = os.environ.get("PROXY_DOUBLING_BREAK", "on").lower() not in {
|
|
375
|
+
"0", "off", "false", "no",
|
|
376
|
+
}
|
|
377
|
+
PROXY_DOUBLING_THRESHOLD = int(os.environ.get("PROXY_DOUBLING_THRESHOLD", "3"))
|
|
378
|
+
|
|
379
|
+
# Failure shapes the generic _ERROR_LINE_RE misses but that motivated this
|
|
380
|
+
# guard (raw GitHub rate-limit JSON, plain timeout lines). Scoped to
|
|
381
|
+
# doubling-down ONLY so ERROR-LOOP's signature semantics stay untouched.
|
|
382
|
+
_DOUBLING_FAIL_RE = re.compile(
|
|
383
|
+
r"rate.?limit|timed?[ -]?out|too many requests|\b429\b|quota exceeded",
|
|
384
|
+
re.IGNORECASE,
|
|
385
|
+
)
|
|
386
|
+
|
|
362
387
|
PROXY_TOOL_STATE_MACHINE = os.environ.get(
|
|
363
388
|
"PROXY_TOOL_STATE_MACHINE", "on"
|
|
364
389
|
).lower() not in {
|
|
@@ -1437,6 +1462,10 @@ class SessionMonitor:
|
|
|
1437
1462
|
stuck_break_fires: int = 0 # monotonic count of forced stuck-breaks
|
|
1438
1463
|
deferral_streak: int = 0 # consecutive no-tool turns deferring the work (Fix A)
|
|
1439
1464
|
deferral_break_fires: int = 0 # monotonic count of forced deferral-breaks (Fix A)
|
|
1465
|
+
doubling_fp: str = "" # fingerprint of the repeated failing tool call (doubling-down)
|
|
1466
|
+
doubling_streak: int = 0 # consecutive FAILED retries of that same call
|
|
1467
|
+
doubling_break_fires: int = 0 # monotonic count of injected pivot directives
|
|
1468
|
+
last_doubling_obs: str = "" # msg-count:fingerprint key of the last counted observation
|
|
1440
1469
|
mandate_deliver_fires: int = 0 # monotonic count of forced deliver-routings (mandate)
|
|
1441
1470
|
tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
|
|
1442
1471
|
last_request_msg_count: int = 0 # Message count of the previous request (compaction-boundary detection)
|
|
@@ -1749,6 +1778,63 @@ class SessionMonitor:
|
|
|
1749
1778
|
return True, f"deferral/plan-capitulation x{self.deferral_streak}"
|
|
1750
1779
|
return False, ""
|
|
1751
1780
|
|
|
1781
|
+
def note_doubling_signal(
|
|
1782
|
+
self,
|
|
1783
|
+
fingerprint: str,
|
|
1784
|
+
latest_result_text: str,
|
|
1785
|
+
msg_count: int = -1,
|
|
1786
|
+
result_error: bool | None = None,
|
|
1787
|
+
) -> None:
|
|
1788
|
+
"""Track the model doubling down on ONE failing action (DOUBLING-DOWN
|
|
1789
|
+
guardrail): the same tool-call fingerprint (name + argument hash)
|
|
1790
|
+
re-issued while its tool_result keeps failing. Any different call, a
|
|
1791
|
+
no-tool turn, or a clean result resets the streak -- only consecutive
|
|
1792
|
+
identical failed retries count, so productive repetition (the same
|
|
1793
|
+
command after a fix, polling that eventually succeeds) is never
|
|
1794
|
+
flagged.
|
|
1795
|
+
|
|
1796
|
+
msg_count dedups re-sent transcripts: a client retry (5xx/stream abort)
|
|
1797
|
+
repeats the same trailing (call, result) pair WITHOUT the conversation
|
|
1798
|
+
growing; observing it twice must not inflate the streak. result_error
|
|
1799
|
+
is the tool_result is_error flag when the client sent one -- it beats
|
|
1800
|
+
the keyword heuristics in both directions."""
|
|
1801
|
+
if not PROXY_DOUBLING_BREAK:
|
|
1802
|
+
self.doubling_streak = 0
|
|
1803
|
+
self.doubling_fp = ""
|
|
1804
|
+
return
|
|
1805
|
+
key = f"{msg_count}:{fingerprint}"
|
|
1806
|
+
if msg_count >= 0 and fingerprint and key == self.last_doubling_obs:
|
|
1807
|
+
return
|
|
1808
|
+
self.last_doubling_obs = key
|
|
1809
|
+
if result_error is not None:
|
|
1810
|
+
failed = result_error
|
|
1811
|
+
else:
|
|
1812
|
+
text = latest_result_text or ""
|
|
1813
|
+
failed = bool(_error_signature(text)) or bool(_DOUBLING_FAIL_RE.search(text))
|
|
1814
|
+
if not fingerprint or not failed:
|
|
1815
|
+
self.doubling_streak = 0
|
|
1816
|
+
self.doubling_fp = fingerprint or ""
|
|
1817
|
+
return
|
|
1818
|
+
if fingerprint == self.doubling_fp:
|
|
1819
|
+
self.doubling_streak += 1
|
|
1820
|
+
else:
|
|
1821
|
+
self.doubling_fp = fingerprint
|
|
1822
|
+
self.doubling_streak = 1
|
|
1823
|
+
|
|
1824
|
+
def reset_doubling(self) -> None:
|
|
1825
|
+
"""Fresh act cycle / fresh user text: drop the doubling streak."""
|
|
1826
|
+
self.doubling_fp = ""
|
|
1827
|
+
self.doubling_streak = 0
|
|
1828
|
+
self.last_doubling_obs = ""
|
|
1829
|
+
|
|
1830
|
+
def should_force_doubling_break(self) -> tuple[bool, str]:
|
|
1831
|
+
"""True + reason when a doubling-down pivot should be injected."""
|
|
1832
|
+
if not PROXY_DOUBLING_BREAK:
|
|
1833
|
+
return False, ""
|
|
1834
|
+
if self.doubling_streak >= PROXY_DOUBLING_THRESHOLD:
|
|
1835
|
+
return True, f"same failing call retried x{self.doubling_streak}"
|
|
1836
|
+
return False, ""
|
|
1837
|
+
|
|
1752
1838
|
def recon_convergence_pending(self) -> bool:
|
|
1753
1839
|
"""True when recon-convergence will own this turn. DEFERRAL-BREAK yields
|
|
1754
1840
|
to it (as it does to STUCK-BREAK) so the two guards never emit
|
|
@@ -4616,6 +4702,7 @@ def _resolve_state_machine_tool_choice(
|
|
|
4616
4702
|
latest_user_text = _latest_user_text(anthropic_body).strip()
|
|
4617
4703
|
if latest_user_text and not last_user_has_tool_result:
|
|
4618
4704
|
monitor.tool_call_history = []
|
|
4705
|
+
monitor.reset_doubling()
|
|
4619
4706
|
if n_msgs <= 1:
|
|
4620
4707
|
monitor.forced_auto_cooldown_turns = 0
|
|
4621
4708
|
monitor.consecutive_forced_count = 0
|
|
@@ -4648,6 +4735,7 @@ def _resolve_state_machine_tool_choice(
|
|
|
4648
4735
|
if not active_loop:
|
|
4649
4736
|
if not has_tool_results:
|
|
4650
4737
|
monitor.tool_call_history = []
|
|
4738
|
+
monitor.reset_doubling()
|
|
4651
4739
|
if n_msgs <= 1:
|
|
4652
4740
|
monitor.forced_auto_cooldown_turns = 0
|
|
4653
4741
|
monitor.consecutive_forced_count = 0
|
|
@@ -5038,6 +5126,58 @@ def _maybe_inject_error_loop_break(openai_body: dict, monitor: "SessionMonitor")
|
|
|
5038
5126
|
)
|
|
5039
5127
|
|
|
5040
5128
|
|
|
5129
|
+
def _maybe_inject_doubling_break(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
5130
|
+
"""Break a doubling-down run: the SAME tool call has failed N times in a row.
|
|
5131
|
+
|
|
5132
|
+
"Never go full": total commitment to one failing action surrenders the
|
|
5133
|
+
judgment needed to step back out. ERROR-LOOP owns the same-error/varied-edit
|
|
5134
|
+
shape and its diagnose-first nudge takes precedence when both would fire;
|
|
5135
|
+
this guard owns the identical-call shape, where the error text may vary
|
|
5136
|
+
turn to turn (rate limits, timeouts, flaky tests) and so keeps ERROR-LOOP
|
|
5137
|
+
reset while the retries burn turns. Advisory like ERROR-LOOP: it does not
|
|
5138
|
+
touch tool_choice -- the model should keep acting, just not with THAT call."""
|
|
5139
|
+
should, reason = monitor.should_force_doubling_break()
|
|
5140
|
+
if not should:
|
|
5141
|
+
return
|
|
5142
|
+
# STUCK-BREAK (a self-aware loop wanting a prose exit) is more urgent.
|
|
5143
|
+
stuck, _ = monitor.should_force_stuck_break()
|
|
5144
|
+
if stuck:
|
|
5145
|
+
return
|
|
5146
|
+
# Recon-convergence may strip tools / force a terminal summary this turn; a
|
|
5147
|
+
# "take a different action" directive would contradict it.
|
|
5148
|
+
if monitor.recon_convergence_pending():
|
|
5149
|
+
return
|
|
5150
|
+
# ERROR-LOOP fires this same turn on the lockstep case (identical call AND
|
|
5151
|
+
# identical error); stacking a second STOP directive would conflict.
|
|
5152
|
+
if PROXY_ERROR_LOOP and monitor.error_signature_streak >= PROXY_ERROR_LOOP_THRESHOLD:
|
|
5153
|
+
return
|
|
5154
|
+
monitor.doubling_break_fires += 1
|
|
5155
|
+
directive = (
|
|
5156
|
+
"\n\nPIVOT -- you have made the EXACT same tool call and it has failed "
|
|
5157
|
+
+ str(monitor.doubling_streak)
|
|
5158
|
+
+ " times in a row ("
|
|
5159
|
+
+ reason
|
|
5160
|
+
+ "). Do not go all-in on one approach: retrying it harder will fail the "
|
|
5161
|
+
"same way. Do NOT issue that call again. State in one sentence a "
|
|
5162
|
+
"DIFFERENT approach -- a different tool, a different target, or a "
|
|
5163
|
+
"smaller step -- and take that action now. If no alternative exists, "
|
|
5164
|
+
"stop and report the blocker in one sentence instead of retrying."
|
|
5165
|
+
)
|
|
5166
|
+
msgs = openai_body.get("messages")
|
|
5167
|
+
if not isinstance(msgs, list):
|
|
5168
|
+
msgs = []
|
|
5169
|
+
if msgs and msgs[0].get("role") == "system":
|
|
5170
|
+
msgs[0]["content"] = (msgs[0].get("content") or "") + directive
|
|
5171
|
+
else:
|
|
5172
|
+
msgs.insert(0, {"role": "system", "content": directive.strip()})
|
|
5173
|
+
openai_body["messages"] = msgs # reattach in case messages was empty/absent
|
|
5174
|
+
logger.warning(
|
|
5175
|
+
"DOUBLING-BREAK: injected pivot directive (%s, fires=%d)",
|
|
5176
|
+
reason,
|
|
5177
|
+
monitor.doubling_break_fires,
|
|
5178
|
+
)
|
|
5179
|
+
|
|
5180
|
+
|
|
5041
5181
|
def _maybe_inject_deferral_break(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
5042
5182
|
"""Convert a turn-ending deferral into forward motion (Fix A).
|
|
5043
5183
|
|
|
@@ -6071,6 +6211,10 @@ def build_openai_request(
|
|
|
6071
6211
|
# ERROR-LOOP: same tool_result failure recurring despite varied edits.
|
|
6072
6212
|
_maybe_inject_error_loop_break(openai_body, monitor)
|
|
6073
6213
|
|
|
6214
|
+
# DOUBLING-DOWN: the SAME call failing repeatedly (error text may vary).
|
|
6215
|
+
# Yields to stuck-break / recon / error-loop inside the function.
|
|
6216
|
+
_maybe_inject_doubling_break(openai_body, monitor)
|
|
6217
|
+
|
|
6074
6218
|
# DEFERRAL-BREAK (Fix A): after stuck-break, drive a no-tool deferral turn
|
|
6075
6219
|
# into a concrete action. Runs last so it can yield to stuck-break.
|
|
6076
6220
|
_maybe_inject_deferral_break(openai_body, monitor)
|
|
@@ -6191,6 +6335,7 @@ def _detect_and_strip_synthetic_continuation(
|
|
|
6191
6335
|
monitor.reset_tool_turn_state(reason="finalize_continuation_resume")
|
|
6192
6336
|
monitor.reset_completion_recovery()
|
|
6193
6337
|
monitor.tool_call_history = []
|
|
6338
|
+
monitor.reset_doubling()
|
|
6194
6339
|
logger.info(
|
|
6195
6340
|
"FINALIZE CONTINUATION: stripped synthetic tool id=%s, "
|
|
6196
6341
|
"reset state machine for fresh act cycle (continuations=%d/%d)",
|
|
@@ -6214,16 +6359,17 @@ def _record_last_assistant_tool_calls(
|
|
|
6214
6359
|
# ERROR-LOOP tracking: feed the monitor the most recent tool_result text so a
|
|
6215
6360
|
# recurring same-failure signature can be detected across (varied) edits.
|
|
6216
6361
|
_latest_tr = ""
|
|
6362
|
+
_latest_err: bool | None = None
|
|
6217
6363
|
for _m in reversed(messages):
|
|
6218
6364
|
_c = _m.get("content")
|
|
6219
6365
|
if isinstance(_c, list):
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
for b in _c
|
|
6223
|
-
if isinstance(b, dict) and b.get("type") == "tool_result"
|
|
6224
|
-
]
|
|
6366
|
+
_blocks = [b for b in _c if isinstance(b, dict) and b.get("type") == "tool_result"]
|
|
6367
|
+
_parts = [_extract_text(b.get("content", "")) for b in _blocks]
|
|
6225
6368
|
if _parts:
|
|
6226
6369
|
_latest_tr = "\n".join(p for p in _parts if p)
|
|
6370
|
+
_flags = [b.get("is_error") for b in _blocks if isinstance(b.get("is_error"), bool)]
|
|
6371
|
+
if _flags:
|
|
6372
|
+
_latest_err = any(_flags)
|
|
6227
6373
|
break
|
|
6228
6374
|
monitor.note_tool_result_error(_latest_tr)
|
|
6229
6375
|
tool_fingerprints = []
|
|
@@ -6265,6 +6411,10 @@ def _record_last_assistant_tool_calls(
|
|
|
6265
6411
|
tool_targets=tool_targets,
|
|
6266
6412
|
fingerprint=fingerprint,
|
|
6267
6413
|
)
|
|
6414
|
+
# DOUBLING-DOWN: pair this turn's call fingerprint with its result.
|
|
6415
|
+
monitor.note_doubling_signal(
|
|
6416
|
+
fingerprint, _latest_tr, msg_count=len(messages), result_error=_latest_err
|
|
6417
|
+
)
|
|
6268
6418
|
return fingerprint
|
|
6269
6419
|
# Fix B: no tool call in the last assistant turn. A plain-text turn is still
|
|
6270
6420
|
# a non-write turn, so advance the recon-convergence streak (previously it
|
|
@@ -6272,6 +6422,9 @@ def _record_last_assistant_tool_calls(
|
|
|
6272
6422
|
# Guard on real prose so an empty/absent assistant turn never inflates it.
|
|
6273
6423
|
if assistant_had_text:
|
|
6274
6424
|
monitor.note_no_tool_turn()
|
|
6425
|
+
monitor.note_doubling_signal(
|
|
6426
|
+
"", _latest_tr, msg_count=len(messages), result_error=_latest_err
|
|
6427
|
+
)
|
|
6275
6428
|
return ""
|
|
6276
6429
|
|
|
6277
6430
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unit tests for the DOUBLING-DOWN guardrail ("never go full").
|
|
3
|
+
|
|
4
|
+
Regression class: the model re-issues the EXACT same tool call while it keeps
|
|
5
|
+
failing -- going all-in on one approach. ERROR-LOOP only catches the inverse
|
|
6
|
+
shape (varied edits, same error signature) and the identical-call cycle
|
|
7
|
+
detector ignores results entirely, so a same-call run whose error text VARIES
|
|
8
|
+
turn to turn (rate limits, timeouts, flaky tests) slipped through every
|
|
9
|
+
guardrail (observed live: a rate-limited GitHub API hammered in a loop). The
|
|
10
|
+
doubling-down breaker pairs the call fingerprint with its result and, after N
|
|
11
|
+
consecutive failed retries of the same call, injects a pivot directive.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_proxy_module():
|
|
20
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
21
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
22
|
+
assert spec is not None and spec.loader is not None
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(module)
|
|
25
|
+
return module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
proxy = _load_proxy_module()
|
|
29
|
+
|
|
30
|
+
FAIL = "Error: rate limit exceeded for url"
|
|
31
|
+
FAIL_OTHER = "TypeError: cannot read properties of undefined"
|
|
32
|
+
OK = "all files written successfully"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestDoublingStreakAccounting(unittest.TestCase):
|
|
36
|
+
def test_same_failing_call_increments(self):
|
|
37
|
+
mon = proxy.SessionMonitor()
|
|
38
|
+
for expected in (1, 2, 3):
|
|
39
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
40
|
+
self.assertEqual(mon.doubling_streak, expected)
|
|
41
|
+
|
|
42
|
+
def test_varied_error_text_still_counts(self):
|
|
43
|
+
# THE gap this guard closes: same call, different error each time.
|
|
44
|
+
mon = proxy.SessionMonitor()
|
|
45
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
46
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL_OTHER)
|
|
47
|
+
mon.note_doubling_signal("Bash:abcd1234", "fatal: connection timed out")
|
|
48
|
+
self.assertEqual(mon.doubling_streak, 3)
|
|
49
|
+
|
|
50
|
+
def test_different_call_resets(self):
|
|
51
|
+
mon = proxy.SessionMonitor()
|
|
52
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
53
|
+
mon.note_doubling_signal("Bash:ffff0000", FAIL)
|
|
54
|
+
self.assertEqual(mon.doubling_streak, 1)
|
|
55
|
+
self.assertEqual(mon.doubling_fp, "Bash:ffff0000")
|
|
56
|
+
|
|
57
|
+
def test_success_resets(self):
|
|
58
|
+
mon = proxy.SessionMonitor()
|
|
59
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
60
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
61
|
+
mon.note_doubling_signal("Bash:abcd1234", OK)
|
|
62
|
+
self.assertEqual(mon.doubling_streak, 0)
|
|
63
|
+
|
|
64
|
+
def test_no_tool_turn_resets(self):
|
|
65
|
+
mon = proxy.SessionMonitor()
|
|
66
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
67
|
+
mon.note_doubling_signal("", FAIL)
|
|
68
|
+
self.assertEqual(mon.doubling_streak, 0)
|
|
69
|
+
|
|
70
|
+
def test_should_force_at_threshold(self):
|
|
71
|
+
mon = proxy.SessionMonitor()
|
|
72
|
+
for _ in range(proxy.PROXY_DOUBLING_THRESHOLD):
|
|
73
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
74
|
+
should, reason = mon.should_force_doubling_break()
|
|
75
|
+
self.assertTrue(should)
|
|
76
|
+
self.assertIn("retried", reason)
|
|
77
|
+
|
|
78
|
+
def test_below_threshold_does_not_force(self):
|
|
79
|
+
mon = proxy.SessionMonitor()
|
|
80
|
+
for _ in range(proxy.PROXY_DOUBLING_THRESHOLD - 1):
|
|
81
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
82
|
+
should, _ = mon.should_force_doubling_break()
|
|
83
|
+
self.assertFalse(should)
|
|
84
|
+
|
|
85
|
+
def test_resent_transcript_does_not_inflate(self):
|
|
86
|
+
# A client retry (5xx / stream abort) re-sends the same trailing
|
|
87
|
+
# (call, result) pair without the conversation growing.
|
|
88
|
+
mon = proxy.SessionMonitor()
|
|
89
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL, msg_count=10)
|
|
90
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL, msg_count=10)
|
|
91
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL, msg_count=10)
|
|
92
|
+
self.assertEqual(mon.doubling_streak, 1)
|
|
93
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL, msg_count=12)
|
|
94
|
+
self.assertEqual(mon.doubling_streak, 2)
|
|
95
|
+
|
|
96
|
+
def test_is_error_flag_counts_without_keywords(self):
|
|
97
|
+
mon = proxy.SessionMonitor()
|
|
98
|
+
mon.note_doubling_signal("Bash:abcd1234", "done", result_error=True)
|
|
99
|
+
mon.note_doubling_signal("Bash:abcd1234", "done", result_error=True)
|
|
100
|
+
self.assertEqual(mon.doubling_streak, 2)
|
|
101
|
+
|
|
102
|
+
def test_is_error_false_beats_error_looking_text(self):
|
|
103
|
+
# grep output legitimately containing "undefined" is a SUCCESS when the
|
|
104
|
+
# client says so -- the explicit flag wins over keyword heuristics.
|
|
105
|
+
mon = proxy.SessionMonitor()
|
|
106
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
107
|
+
mon.note_doubling_signal("Bash:abcd1234", "undefined symbol listing", result_error=False)
|
|
108
|
+
self.assertEqual(mon.doubling_streak, 0)
|
|
109
|
+
|
|
110
|
+
def test_raw_rate_limit_body_counts_as_failure(self):
|
|
111
|
+
# The motivating incident: GitHub's raw body has none of the generic
|
|
112
|
+
# error keywords.
|
|
113
|
+
mon = proxy.SessionMonitor()
|
|
114
|
+
body = '{"message": "API rate limit exceeded for 1.2.3.4"}'
|
|
115
|
+
mon.note_doubling_signal("Bash:abcd1234", body)
|
|
116
|
+
mon.note_doubling_signal("Bash:abcd1234", body)
|
|
117
|
+
self.assertEqual(mon.doubling_streak, 2)
|
|
118
|
+
|
|
119
|
+
def test_reset_doubling_clears_state(self):
|
|
120
|
+
mon = proxy.SessionMonitor()
|
|
121
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
122
|
+
mon.reset_doubling()
|
|
123
|
+
self.assertEqual(mon.doubling_streak, 0)
|
|
124
|
+
self.assertEqual(mon.doubling_fp, "")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _body():
|
|
128
|
+
return {
|
|
129
|
+
"tools": [{"type": "function", "function": {"name": "run_bash"}}],
|
|
130
|
+
"tool_choice": "auto",
|
|
131
|
+
"messages": [{"role": "system", "content": "base"}],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _saturated_monitor():
|
|
136
|
+
mon = proxy.SessionMonitor()
|
|
137
|
+
for _ in range(proxy.PROXY_DOUBLING_THRESHOLD):
|
|
138
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
139
|
+
return mon
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestDoublingInjection(unittest.TestCase):
|
|
143
|
+
def test_fires_past_threshold(self):
|
|
144
|
+
mon = _saturated_monitor()
|
|
145
|
+
body = _body()
|
|
146
|
+
proxy._maybe_inject_doubling_break(body, mon)
|
|
147
|
+
self.assertEqual(mon.doubling_break_fires, 1)
|
|
148
|
+
self.assertIn("PIVOT", body["messages"][0]["content"])
|
|
149
|
+
# Advisory: never touches tool_choice (unlike deferral-break).
|
|
150
|
+
self.assertEqual(body["tool_choice"], "auto")
|
|
151
|
+
|
|
152
|
+
def test_no_fire_below_threshold(self):
|
|
153
|
+
mon = proxy.SessionMonitor()
|
|
154
|
+
mon.note_doubling_signal("Bash:abcd1234", FAIL)
|
|
155
|
+
body = _body()
|
|
156
|
+
proxy._maybe_inject_doubling_break(body, mon)
|
|
157
|
+
self.assertEqual(mon.doubling_break_fires, 0)
|
|
158
|
+
self.assertEqual(body["messages"][0]["content"], "base")
|
|
159
|
+
|
|
160
|
+
def test_yields_to_stuck_break(self):
|
|
161
|
+
mon = _saturated_monitor()
|
|
162
|
+
mon.self_stuck_streak = 99
|
|
163
|
+
body = _body()
|
|
164
|
+
proxy._maybe_inject_doubling_break(body, mon)
|
|
165
|
+
self.assertEqual(mon.doubling_break_fires, 0)
|
|
166
|
+
|
|
167
|
+
def test_yields_to_recon_convergence(self):
|
|
168
|
+
mon = _saturated_monitor()
|
|
169
|
+
if proxy.PROXY_RECON_CONVERGENCE_THRESHOLD <= 0:
|
|
170
|
+
self.skipTest("recon-convergence disabled in this environment")
|
|
171
|
+
mon.consecutive_no_write_turns = proxy.PROXY_RECON_CONVERGENCE_THRESHOLD
|
|
172
|
+
body = _body()
|
|
173
|
+
proxy._maybe_inject_doubling_break(body, mon)
|
|
174
|
+
self.assertEqual(mon.doubling_break_fires, 0)
|
|
175
|
+
|
|
176
|
+
def test_yields_to_error_loop(self):
|
|
177
|
+
# Lockstep case: identical call AND identical error -- ERROR-LOOP owns it.
|
|
178
|
+
mon = _saturated_monitor()
|
|
179
|
+
mon.error_signature_streak = proxy.PROXY_ERROR_LOOP_THRESHOLD
|
|
180
|
+
body = _body()
|
|
181
|
+
proxy._maybe_inject_doubling_break(body, mon)
|
|
182
|
+
self.assertEqual(mon.doubling_break_fires, 0)
|
|
183
|
+
|
|
184
|
+
def test_inserts_system_message_when_absent(self):
|
|
185
|
+
mon = _saturated_monitor()
|
|
186
|
+
body = {"tools": [], "messages": [{"role": "user", "content": "hi"}]}
|
|
187
|
+
proxy._maybe_inject_doubling_break(body, mon)
|
|
188
|
+
self.assertEqual(body["messages"][0]["role"], "system")
|
|
189
|
+
self.assertIn("PIVOT", body["messages"][0]["content"])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestRecordingWiring(unittest.TestCase):
|
|
193
|
+
def _turn(self, command: str, result_text: str) -> dict:
|
|
194
|
+
return {
|
|
195
|
+
"messages": [
|
|
196
|
+
{
|
|
197
|
+
"role": "assistant",
|
|
198
|
+
"content": [
|
|
199
|
+
{
|
|
200
|
+
"type": "tool_use",
|
|
201
|
+
"id": "t1",
|
|
202
|
+
"name": "run_bash",
|
|
203
|
+
"input": {"command": command},
|
|
204
|
+
}
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"role": "user",
|
|
209
|
+
"content": [
|
|
210
|
+
{"type": "tool_result", "tool_use_id": "t1", "content": result_text}
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def test_identical_failing_turns_accumulate_via_recording(self):
|
|
217
|
+
mon = proxy.SessionMonitor()
|
|
218
|
+
history: list = []
|
|
219
|
+
for _ in range(3):
|
|
220
|
+
history.extend(self._turn("curl https://api.example.com/rate-limited", FAIL)["messages"])
|
|
221
|
+
proxy._record_last_assistant_tool_calls({"messages": list(history)}, mon)
|
|
222
|
+
self.assertEqual(mon.doubling_streak, 3)
|
|
223
|
+
|
|
224
|
+
def test_resent_identical_request_via_recording_counts_once(self):
|
|
225
|
+
mon = proxy.SessionMonitor()
|
|
226
|
+
body = self._turn("curl https://api.example.com/rate-limited", FAIL)
|
|
227
|
+
proxy._record_last_assistant_tool_calls(body, mon)
|
|
228
|
+
proxy._record_last_assistant_tool_calls(body, mon) # client resend
|
|
229
|
+
self.assertEqual(mon.doubling_streak, 1)
|
|
230
|
+
|
|
231
|
+
def test_different_commands_do_not_accumulate(self):
|
|
232
|
+
mon = proxy.SessionMonitor()
|
|
233
|
+
proxy._record_last_assistant_tool_calls(self._turn("cmd one", FAIL), mon)
|
|
234
|
+
proxy._record_last_assistant_tool_calls(self._turn("cmd two", FAIL), mon)
|
|
235
|
+
self.assertEqual(mon.doubling_streak, 1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
unittest.main()
|