@miller-tech/uap 1.186.1 → 1.186.3

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.
@@ -374,6 +374,30 @@ _RATE_LIMITED_API_RE = re.compile(r"api\.github\.com", re.IGNORECASE)
374
374
  PROXY_STUCK_TEXT_THRESHOLD = int(os.environ.get("PROXY_STUCK_TEXT_THRESHOLD", "2"))
375
375
  PROXY_STUCK_API_THRESHOLD = int(os.environ.get("PROXY_STUCK_API_THRESHOLD", "3"))
376
376
 
377
+ # REPEAT-CALL guardrail: the same tool call, with the same arguments, over and
378
+ # over -- while SUCCEEDING every time.
379
+ #
380
+ # Observed live (opencode + qwen3.6, 2026-08-07): `git diff --stat` re-issued 44
381
+ # times in one run, ~2.5s apart, until the operator interrupted. On screen it
382
+ # reads as the final message repeating forever.
383
+ #
384
+ # Every existing guard missed it, and each for a defensible reason:
385
+ # STUCK-BREAK needs self-reported "stuck" phrasing or an api.github.com arg.
386
+ # ERROR-LOOP needs a repeated tool-RESULT error signature; this call works.
387
+ # LOOP BREAKER detects the identical fingerprint, but is ANDed with
388
+ # no_progress_streak -- and a command that returns output every
389
+ # time never accumulates one, so the condition never holds.
390
+ #
391
+ # The blind spot is therefore a repeatedly-SUCCESSFUL identical call: the other
392
+ # guards all key off failure or self-awareness, and this loop has neither. A
393
+ # read-only command issued four times with identical arguments is not a
394
+ # strategy, so this fires on the fingerprint alone, independent of outcome.
395
+ # PROXY_REPEAT_CALL_THRESHOLD=0 disables.
396
+ PROXY_REPEAT_CALL_THRESHOLD = int(os.environ.get("PROXY_REPEAT_CALL_THRESHOLD", "4"))
397
+ # Marker so the injected directive can address a SUCCEEDING loop correctly
398
+ # rather than telling the model to stop retrying "a failing action".
399
+ _REPEAT_CALL_REASON = "identical tool call"
400
+
377
401
  # ---------------------------------------------------------------------------
378
402
  # ERROR-LOOP guardrail: the model edits, runs a command, hits the SAME failure,
379
403
  # edits again (a DIFFERENT edit), runs, hits the same failure — for many turns.
@@ -1993,6 +2017,14 @@ class SessionMonitor:
1993
2017
  return True, f"self-reported stuck x{self.self_stuck_streak}"
1994
2018
  if self.rate_limited_api_streak >= PROXY_STUCK_API_THRESHOLD:
1995
2019
  return True, f"rate-limited-API retries x{self.rate_limited_api_streak}"
2020
+ # Repeated identical call, judged on the fingerprint ALONE. Deliberately
2021
+ # not ANDed with no_progress_streak the way the LOOP BREAKER is: a call
2022
+ # that succeeds every time never builds a no-progress streak, which is
2023
+ # exactly how a 44-turn `git diff --stat` loop ran unchallenged.
2024
+ if PROXY_REPEAT_CALL_THRESHOLD > 0:
2025
+ looping, count = self.detect_tool_loop(window=PROXY_REPEAT_CALL_THRESHOLD)
2026
+ if looping and count >= PROXY_REPEAT_CALL_THRESHOLD:
2027
+ return True, f"{_REPEAT_CALL_REASON} x{count}"
1996
2028
  return False, ""
1997
2029
 
1998
2030
  def note_deferral_signal(self, text: str, had_tool_call: bool) -> None:
@@ -5677,6 +5709,51 @@ def _strip_sandbox_unreachable_tools(body: dict) -> int:
5677
5709
  return removed
5678
5710
 
5679
5711
 
5712
+ def _seed_tool_history_from_request(monitor: "SessionMonitor", messages: list) -> None:
5713
+ """Rebuild the tool-call streak from the CONVERSATION, not from server state.
5714
+
5715
+ Every streak guard here counted appends to a per-session SessionMonitor. That
5716
+ monitor is keyed `fp:<hash of the first user message>` whenever the client
5717
+ sends no session header — and opencode sends none. So anything that shifts
5718
+ that text (compaction, a re-summarised opening turn) silently starts a FRESH
5719
+ monitor with empty history, and every streak restarts at zero. A proxy
5720
+ restart mid-session does the same.
5721
+
5722
+ The client re-sends the whole conversation each turn, so the streak is
5723
+ already in the request. Deriving it from there makes the guards independent
5724
+ of monitor identity and of proxy uptime.
5725
+
5726
+ Only ever EXTENDS: if the monitor already knows at least as much as the
5727
+ request implies, it is left alone, so this cannot double-count the normal
5728
+ path that appends one fingerprint per request.
5729
+ """
5730
+ if not isinstance(messages, list):
5731
+ return
5732
+ rebuilt: list[str] = []
5733
+ for msg in messages:
5734
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
5735
+ continue
5736
+ content = msg.get("content")
5737
+ if not isinstance(content, list):
5738
+ continue
5739
+ fps = [
5740
+ _tool_call_fingerprint(b)
5741
+ for b in content
5742
+ if isinstance(b, dict) and b.get("type") == "tool_use"
5743
+ ]
5744
+ if fps:
5745
+ rebuilt.append("|".join(sorted(fps)))
5746
+ # Drop the LAST turn: the incremental path immediately appends that one, and
5747
+ # seeding it here too would count the current turn twice — inflating every
5748
+ # streak by one and firing the guards a turn early. Caught by two existing
5749
+ # streaming tests, which replay one body three times and reached the
5750
+ # threshold sooner than they should have.
5751
+ rebuilt = rebuilt[:-1]
5752
+ if len(rebuilt) > len(monitor.tool_call_history):
5753
+ # Keep the same bound the incremental path uses.
5754
+ monitor.tool_call_history = rebuilt[-30:]
5755
+
5756
+
5680
5757
  def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> None:
5681
5758
  """Force a terminal turn when the model is looping self-awarely or hammering
5682
5759
  a rate-limited API. Unlike the cycle-breaker (which narrows tools), this
@@ -5697,15 +5774,29 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
5697
5774
  if monitor.sandboxed
5698
5775
  else "use the browser tool or `git clone` (git protocol)"
5699
5776
  )
5700
- directive = (
5701
- "\n\nSTOP you are repeating a failing action (" + reason + "). Do NOT "
5702
- "retry the same tool or fetch again. If a resource is unreachable (e.g. a "
5703
- "rate-limited GitHub REST API), switch channel: " + channel_hint + ", NOT "
5704
- "api.github.com. If it is still "
5705
- "unavailable, proceed WITHOUT it using what you already have, or ask the "
5706
- "operator the single blocking question in one sentence. Take a DIFFERENT "
5707
- "action now."
5708
- )
5777
+ if reason.startswith(_REPEAT_CALL_REASON):
5778
+ # The call SUCCEEDS every time, so "stop retrying a failing action" and
5779
+ # the switch-channel advice would both be nonsense here. Name the real
5780
+ # problem: the answer is already in hand and re-asking cannot change it.
5781
+ directive = (
5782
+ "\n\nSTOP you have issued the same tool call with the same "
5783
+ "arguments " + reason.rsplit("x", 1)[-1] + " times in a row. It "
5784
+ "SUCCEEDED each time and the result will not change by asking again. "
5785
+ "You already have that output. Do NOT repeat it. Either take the "
5786
+ "NEXT concrete action using what it told you, or — if you genuinely "
5787
+ "cannot proceed — state the single blocking question in one sentence "
5788
+ "and stop. Answer in plain text now if the work is done."
5789
+ )
5790
+ else:
5791
+ directive = (
5792
+ "\n\nSTOP — you are repeating a failing action (" + reason + "). Do NOT "
5793
+ "retry the same tool or fetch again. If a resource is unreachable (e.g. a "
5794
+ "rate-limited GitHub REST API), switch channel: " + channel_hint + ", NOT "
5795
+ "api.github.com. If it is still "
5796
+ "unavailable, proceed WITHOUT it using what you already have, or ask the "
5797
+ "operator the single blocking question in one sentence. Take a DIFFERENT "
5798
+ "action now."
5799
+ )
5709
5800
  msgs = openai_body.get("messages")
5710
5801
  if not isinstance(msgs, list):
5711
5802
  msgs = []
@@ -7095,6 +7186,7 @@ def _record_last_assistant_tool_calls(
7095
7186
  _latest_err = any(_flags)
7096
7187
  break
7097
7188
  monitor.note_tool_result_error(_latest_tr, _latest_err)
7189
+ _seed_tool_history_from_request(monitor, messages)
7098
7190
  tool_fingerprints = []
7099
7191
  tool_targets: dict[str, str] = {}
7100
7192
  assistant_had_text = False # Fix B: did the last assistant turn emit prose?
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ """A tool call that keeps SUCCEEDING can loop forever, and every guard missed it.
3
+
4
+ Observed live (opencode + qwen3.6, 2026-08-07): `git diff --stat` re-issued 44
5
+ times in one run, ~2.5s apart, until the operator interrupted. On screen it
6
+ reads as the final message repeating without end.
7
+
8
+ Each existing guard declined it for a defensible reason:
9
+
10
+ STUCK-BREAK wants self-reported "stuck" phrasing, or an `api.github.com`
11
+ argument. The model said nothing and this is a git command.
12
+ ERROR-LOOP wants a repeated tool-RESULT error signature. This call works.
13
+ LOOP BREAKER does detect the identical fingerprint, but ANDs it with
14
+ `no_progress_streak` — and a command that returns output every
15
+ time never accumulates one, so the condition never held.
16
+
17
+ The blind spot is a repeatedly-SUCCESSFUL identical call: the other guards all
18
+ key off failure or self-awareness, and this loop has neither. So this guard
19
+ fires on the fingerprint alone, at 4, independent of outcome.
20
+ """
21
+
22
+ import importlib.util
23
+ import os
24
+ import sys
25
+ import unittest
26
+ from pathlib import Path
27
+
28
+ PROXY = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
29
+
30
+
31
+ def load_proxy(env: dict | None = None):
32
+ """Import the proxy module with a chosen env (thresholds are read at import)."""
33
+ saved = dict(os.environ)
34
+ os.environ.update(env or {})
35
+ try:
36
+ spec = importlib.util.spec_from_file_location(f"proxy_{len(sys.modules)}", PROXY)
37
+ mod = importlib.util.module_from_spec(spec)
38
+ spec.loader.exec_module(mod)
39
+ return mod
40
+ finally:
41
+ os.environ.clear()
42
+ os.environ.update(saved)
43
+
44
+
45
+ class TestRepeatCallGuard(unittest.TestCase):
46
+ @classmethod
47
+ def setUpClass(cls):
48
+ cls.proxy = load_proxy()
49
+
50
+ def monitor(self):
51
+ return self.proxy.SessionMonitor(context_window=100000)
52
+
53
+ def record(self, mon, fingerprint, times):
54
+ for _ in range(times):
55
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint=fingerprint)
56
+
57
+ def test_the_observed_loop_is_caught(self):
58
+ # The real thing: same tool, same args, succeeding every time.
59
+ mon = self.monitor()
60
+ self.record(mon, "Bash|git diff --stat", 4)
61
+ should, reason = mon.should_force_stuck_break()
62
+ self.assertTrue(should, "4 identical successful calls must break the loop")
63
+ self.assertIn("identical tool call", reason)
64
+
65
+ def test_three_identical_calls_are_left_alone(self):
66
+ # Repetition is not automatically a loop — a couple of retries is normal.
67
+ mon = self.monitor()
68
+ self.record(mon, "Bash|git diff --stat", 3)
69
+ should, _ = mon.should_force_stuck_break()
70
+ self.assertFalse(should)
71
+
72
+ def test_no_progress_streak_is_NOT_required(self):
73
+ # The precise reason the existing LOOP BREAKER never fired: a succeeding
74
+ # command leaves no_progress_streak at 0 forever.
75
+ mon = self.monitor()
76
+ self.record(mon, "Bash|git diff --stat", 6)
77
+ self.assertEqual(mon.no_progress_streak, 0)
78
+ should, _ = mon.should_force_stuck_break()
79
+ self.assertTrue(should)
80
+
81
+ def test_varied_calls_do_not_trip_it(self):
82
+ # Ordinary agentic work: different tools, different arguments.
83
+ mon = self.monitor()
84
+ for fp in ("Bash|ls", "Read|a.ts", "Bash|npm test", "Edit|a.ts", "Bash|git status"):
85
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint=fp)
86
+ should, _ = mon.should_force_stuck_break()
87
+ self.assertFalse(should)
88
+
89
+ def test_a_broken_streak_resets_it(self):
90
+ # Three repeats, something else, three repeats — not a loop.
91
+ mon = self.monitor()
92
+ self.record(mon, "Bash|git diff --stat", 3)
93
+ mon.record_tool_calls(tool_names=["Read"], fingerprint="Read|src/index.ts")
94
+ self.record(mon, "Bash|git diff --stat", 3)
95
+ should, _ = mon.should_force_stuck_break()
96
+ self.assertFalse(should)
97
+
98
+ def test_the_guard_can_be_disabled(self):
99
+ proxy = load_proxy({"PROXY_REPEAT_CALL_THRESHOLD": "0"})
100
+ mon = proxy.SessionMonitor(context_window=100000)
101
+ for _ in range(12):
102
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
103
+ should, _ = mon.should_force_stuck_break()
104
+ self.assertFalse(should, "PROXY_REPEAT_CALL_THRESHOLD=0 must disable it")
105
+
106
+ def test_existing_stuck_signals_still_work(self):
107
+ # The new branch must not shadow the two guards that were already there.
108
+ mon = self.monitor()
109
+ for _ in range(self.proxy.PROXY_STUCK_TEXT_THRESHOLD):
110
+ mon.note_assistant_text("I've been stuck in a loop, let me break out")
111
+ should, reason = mon.should_force_stuck_break()
112
+ self.assertTrue(should)
113
+ self.assertIn("self-reported", reason)
114
+
115
+
116
+ class TestDirectiveMatchesTheFailure(unittest.TestCase):
117
+ """A succeeding loop must not be told to stop retrying 'a failing action'."""
118
+
119
+ @classmethod
120
+ def setUpClass(cls):
121
+ cls.proxy = load_proxy()
122
+
123
+ def inject(self, mon):
124
+ body = {"messages": [{"role": "system", "content": "base"}], "tool_choice": "required"}
125
+ self.proxy._maybe_inject_stuck_break(body, mon)
126
+ return body
127
+
128
+ def test_repeat_loop_gets_the_right_words(self):
129
+ mon = self.proxy.SessionMonitor(context_window=100000)
130
+ for _ in range(4):
131
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
132
+ text = self.inject(mon)["messages"][0]["content"]
133
+ self.assertIn("SUCCEEDED each time", text)
134
+ self.assertIn("will not change", text)
135
+ # Wrong-diagnosis wording from the other branch must not appear.
136
+ self.assertNotIn("failing action", text)
137
+ self.assertNotIn("api.github.com", text)
138
+
139
+ def test_tool_choice_is_released_so_a_text_turn_is_possible(self):
140
+ # Without this the model is still coerced into calling a tool, which is
141
+ # the loop it is being asked to leave.
142
+ mon = self.proxy.SessionMonitor(context_window=100000)
143
+ for _ in range(4):
144
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
145
+ self.assertEqual(self.inject(mon)["tool_choice"], "auto")
146
+
147
+ def test_failing_loop_keeps_its_original_directive(self):
148
+ mon = self.proxy.SessionMonitor(context_window=100000)
149
+ for _ in range(self.proxy.PROXY_STUCK_API_THRESHOLD):
150
+ mon.note_tool_arg_hosts(["https://api.github.com/repos/x/y"])
151
+ text = self.inject(mon)["messages"][0]["content"]
152
+ self.assertIn("failing action", text)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ unittest.main()
157
+
158
+
159
+ class TestStreakSurvivesAFreshMonitor(unittest.TestCase):
160
+ """The guards counted server-side state that silently resets.
161
+
162
+ The monitor is keyed `fp:<hash of the first user message>` when the client
163
+ sends no session header — and opencode sends none. Compaction, a re-summarised
164
+ opening turn, or a proxy restart therefore starts a FRESH monitor with empty
165
+ history, and every streak restarts at zero no matter how long the real loop is.
166
+
167
+ The conversation is re-sent whole each turn, so the streak is derivable from
168
+ the request. These tests pin that.
169
+ """
170
+
171
+ @classmethod
172
+ def setUpClass(cls):
173
+ cls.proxy = load_proxy()
174
+
175
+ @staticmethod
176
+ def convo(n, cmd="git diff --stat"):
177
+ """A conversation containing `n` identical assistant tool calls."""
178
+ msgs = [{"role": "user", "content": "check the diff"}]
179
+ for i in range(n):
180
+ msgs.append({"role": "assistant", "content": [
181
+ {"type": "tool_use", "id": f"t{i}", "name": "Bash", "input": {"command": cmd}}]})
182
+ msgs.append({"role": "user", "content": [
183
+ {"type": "tool_result", "tool_use_id": f"t{i}", "content": "1 file changed"}]})
184
+ return msgs
185
+
186
+ def test_a_fresh_monitor_still_sees_the_loop(self):
187
+ # THE RESIDUAL: brand-new monitor, 44-turn loop already in the transcript.
188
+ mon = self.proxy.SessionMonitor(context_window=100000)
189
+ self.assertEqual(mon.tool_call_history, [])
190
+ self.proxy._seed_tool_history_from_request(mon, self.convo(44))
191
+ should, reason = mon.should_force_stuck_break()
192
+ self.assertTrue(should, "a reset monitor must not erase a live loop")
193
+ self.assertIn("identical tool call", reason)
194
+
195
+ def test_it_only_extends_never_double_counts(self):
196
+ # The incremental path appends one fingerprint per request; seeding must
197
+ # not stack on top of that and inflate the streak.
198
+ mon = self.proxy.SessionMonitor(context_window=100000)
199
+ for _ in range(6):
200
+ mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|x")
201
+ before = list(mon.tool_call_history)
202
+ self.proxy._seed_tool_history_from_request(mon, self.convo(2))
203
+ self.assertEqual(mon.tool_call_history, before)
204
+
205
+ def test_varied_history_is_reconstructed_without_tripping(self):
206
+ mon = self.proxy.SessionMonitor(context_window=100000)
207
+ msgs = [{"role": "user", "content": "go"}]
208
+ for i, cmd in enumerate(["ls", "npm test", "git status", "npm run build", "ls -la"]):
209
+ msgs.append({"role": "assistant", "content": [
210
+ {"type": "tool_use", "id": f"t{i}", "name": "Bash", "input": {"command": cmd}}]})
211
+ self.proxy._seed_tool_history_from_request(mon, msgs)
212
+ # 5 assistant turns, 4 seeded: the last is deliberately left for the
213
+ # incremental path to append, so the current turn is not counted twice.
214
+ self.assertEqual(len(mon.tool_call_history), 4)
215
+ should, _ = mon.should_force_stuck_break()
216
+ self.assertFalse(should)
217
+
218
+ def test_it_is_bounded(self):
219
+ mon = self.proxy.SessionMonitor(context_window=100000)
220
+ self.proxy._seed_tool_history_from_request(mon, self.convo(200))
221
+ self.assertLessEqual(len(mon.tool_call_history), 30)
222
+
223
+ def test_malformed_input_is_survivable(self):
224
+ mon = self.proxy.SessionMonitor(context_window=100000)
225
+ for bad in (None, "not-a-list", [], [None, 3, {"role": "assistant"}],
226
+ [{"role": "assistant", "content": "plain text"}]):
227
+ self.proxy._seed_tool_history_from_request(mon, bad)
228
+ self.assertEqual(mon.tool_call_history, [])