@miller-tech/uap 1.159.0 → 1.160.0

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.
@@ -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
- _parts = [
6221
- _extract_text(b.get("content", ""))
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()