@miller-tech/uap 1.148.5 → 1.148.7

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.
@@ -729,6 +729,20 @@ PROXY_RECON_HARD_MULTIPLIER = max(1.0, float(
729
729
  PROXY_RECON_SESSION_HARD_CAP = int(
730
730
  os.environ.get("PROXY_RECON_SESSION_HARD_CAP", "3")
731
731
  )
732
+ # Empty-tool-loop breaker: under a forced tool_choice='required' (e.g. the recon
733
+ # guardrail, or the first-turn/active-loop force), a model that has nothing valid
734
+ # to call can return a FULLY-EMPTY response (0 output tokens, no text, no tool
735
+ # call — logged as `finish=tool_use output_tokens=0`). It cannot say "I'm stuck /
736
+ # that file is missing" because the force forbids prose, so it spins: empty ->
737
+ # guardrail re-forces -> empty. That reads as a frozen session (observed live:
738
+ # 16 empties in 10 min on a project whose deliverable dir had been deleted). After
739
+ # this many CONSECUTIVE fully-empty responses, release tool_choice to 'auto' for
740
+ # the next turn and inject a directive telling the model to answer in PLAIN TEXT
741
+ # (state the blocker / ask the user), converting a silent spin into a recoverable
742
+ # ask. 0 disables.
743
+ PROXY_EMPTY_TOOL_LOOP_BREAK = int(
744
+ os.environ.get("PROXY_EMPTY_TOOL_LOOP_BREAK", "3")
745
+ )
732
746
  # Fix F: context death-spiral breaker. When the *raw* (pre-prune) incoming
733
747
  # context stays catastrophically over the window for several consecutive turns,
734
748
  # releasing tool_choice to 'auto' (Fix B / LOOP BREAKER) is NOT enough — the
@@ -1410,6 +1424,7 @@ class SessionMonitor:
1410
1424
  loop_warnings_emitted: int = 0 # How many loop warnings sent to the model
1411
1425
  no_progress_streak: int = 0 # Forced tool turns without new tool_result
1412
1426
  consecutive_no_write_turns: int = 0 # turns exploring with no write tool (B1)
1427
+ consecutive_empty_tool_turns: int = 0 # fully-empty responses (empty-tool loop breaker)
1413
1428
  recon_hard_fires: int = 0 # Fix E: monotonic count of recon hard-tier firings
1414
1429
  catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
1415
1430
  unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
@@ -5192,6 +5207,44 @@ def _maybe_inject_mandate_deliver(openai_body: dict, monitor: "SessionMonitor")
5192
5207
  )
5193
5208
 
5194
5209
 
5210
+ def _maybe_break_empty_tool_loop(openai_body: dict, monitor: "SessionMonitor") -> None:
5211
+ """Release a forced tool_choice after repeated FULLY-EMPTY responses.
5212
+
5213
+ When tool_choice is forced ('required') and the model has nothing valid to
5214
+ call, it can return empty (0 tokens, no text, no tool call) — and it cannot
5215
+ say why, because the force forbids prose. That empties-loop is a silent spin
5216
+ (looks like a frozen session). After PROXY_EMPTY_TOOL_LOOP_BREAK consecutive
5217
+ empties, release tool_choice to 'auto' and inject a plain-text directive so
5218
+ the model can state the blocker or ask the user, turning the spin into a
5219
+ recoverable ask. Resets the streak so it fires once per window, not every turn.
5220
+ """
5221
+ if PROXY_EMPTY_TOOL_LOOP_BREAK <= 0:
5222
+ return
5223
+ if monitor.consecutive_empty_tool_turns < PROXY_EMPTY_TOOL_LOOP_BREAK:
5224
+ return
5225
+ n = monitor.consecutive_empty_tool_turns
5226
+ # Release the force so prose is possible this turn.
5227
+ if openai_body.get("tool_choice") == "required":
5228
+ openai_body["tool_choice"] = "auto"
5229
+ openai_body.pop("grammar", None)
5230
+ directive = (
5231
+ f"You have returned {n} empty responses in a row — you are stuck and a tool "
5232
+ "call is being forced, but you cannot produce a valid one. STOP trying to "
5233
+ "call a tool this turn. Reply in PLAIN TEXT: state exactly what is blocking "
5234
+ "you (for example, a file or directory you expected does not exist), and ask "
5235
+ "the user how they want to proceed. Do NOT call any tool this turn."
5236
+ )
5237
+ msgs = openai_body.get("messages", [])
5238
+ msgs.append({"role": "user", "content": directive})
5239
+ openai_body["messages"] = msgs
5240
+ monitor.consecutive_empty_tool_turns = 0 # one-shot per window; don't force-release forever
5241
+ logger.warning(
5242
+ "EMPTY-TOOL LOOP BREAK: %d consecutive empty responses -> released "
5243
+ "tool_choice->auto + plain-text directive so the model can report the blocker",
5244
+ n,
5245
+ )
5246
+
5247
+
5195
5248
  def _maybe_inject_recon_convergence(
5196
5249
  openai_body: dict,
5197
5250
  monitor: "SessionMonitor",
@@ -6022,6 +6075,13 @@ def build_openai_request(
6022
6075
  # into a concrete action. Runs last so it can yield to stuck-break.
6023
6076
  _maybe_inject_deferral_break(openai_body, monitor)
6024
6077
 
6078
+ # EMPTY-TOOL LOOP BREAK: after repeated FULLY-EMPTY responses under a forced
6079
+ # tool_choice, release the force + inject a plain-text directive so the model
6080
+ # can report the blocker (e.g. a deleted deliverable) instead of spinning.
6081
+ # Runs last among the message/tool_choice guards so its release is final —
6082
+ # the other guards here only ever FORCE, and this is the escape hatch.
6083
+ _maybe_break_empty_tool_loop(openai_body, monitor)
6084
+
6025
6085
  _apply_thinking_grammar(openai_body)
6026
6086
 
6027
6087
  _apply_json_response_grammar(openai_body, anthropic_body)
@@ -10082,6 +10142,14 @@ async def stream_anthropic_response(
10082
10142
  # DEFERRAL-BREAK signal (Fix A): a no-tool prose turn that defers the
10083
10143
  # work. `tc_names` empty means this turn emitted no tool call.
10084
10144
  monitor.note_deferral_signal(accumulated_text, bool(tc_names))
10145
+ # EMPTY-TOOL-LOOP signal: a fully-empty response — no text, no tool call,
10146
+ # zero output tokens (typically `finish=tool_use` under a forced
10147
+ # tool_choice the model can't satisfy). Streak it so the next request can
10148
+ # release the force and let the model speak. Any non-empty turn resets it.
10149
+ _empty_resp = (output_tokens == 0) and (len(accumulated_text) == 0) and (not tc_names)
10150
+ monitor.consecutive_empty_tool_turns = (
10151
+ monitor.consecutive_empty_tool_turns + 1 if _empty_resp else 0
10152
+ )
10085
10153
  except Exception:
10086
10154
  pass
10087
10155
 
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """Empty-tool-loop breaker: after N consecutive fully-empty responses under a
3
+ forced tool_choice, release the force + inject a plain-text directive so a stuck
4
+ model can report the blocker (e.g. a deleted deliverable) instead of spinning."""
5
+ import importlib.util
6
+ import os
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+
11
+ def _load_proxy():
12
+ os.environ.setdefault("PROXY_EMPTY_TOOL_LOOP_BREAK", "3")
13
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
14
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
15
+ m = importlib.util.module_from_spec(spec)
16
+ spec.loader.exec_module(m)
17
+ return m
18
+
19
+
20
+ proxy = _load_proxy()
21
+
22
+
23
+ def forced_body():
24
+ return {"tool_choice": "required", "messages": [{"role": "user", "content": "go"}]}
25
+
26
+
27
+ class EmptyToolLoopBreakTests(unittest.TestCase):
28
+ def setUp(self):
29
+ self.m = proxy.SessionMonitor()
30
+
31
+ def test_no_op_below_threshold(self):
32
+ body = forced_body()
33
+ self.m.consecutive_empty_tool_turns = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK - 1
34
+ proxy._maybe_break_empty_tool_loop(body, self.m)
35
+ self.assertEqual(body["tool_choice"], "required") # force intact
36
+ self.assertEqual(len(body["messages"]), 1) # no directive injected
37
+
38
+ def test_releases_and_directs_at_threshold(self):
39
+ body = forced_body()
40
+ self.m.consecutive_empty_tool_turns = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK
41
+ proxy._maybe_break_empty_tool_loop(body, self.m)
42
+ self.assertEqual(body["tool_choice"], "auto") # force released → prose possible
43
+ self.assertEqual(len(body["messages"]), 2) # plain-text directive appended
44
+ self.assertIn("PLAIN TEXT", body["messages"][-1]["content"])
45
+ self.assertEqual(self.m.consecutive_empty_tool_turns, 0) # one-shot per window
46
+
47
+ def test_disabled_when_threshold_zero(self):
48
+ body = forced_body()
49
+ old = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK
50
+ try:
51
+ proxy.PROXY_EMPTY_TOOL_LOOP_BREAK = 0
52
+ self.m.consecutive_empty_tool_turns = 99
53
+ proxy._maybe_break_empty_tool_loop(body, self.m)
54
+ self.assertEqual(body["tool_choice"], "required") # disabled → no-op
55
+ finally:
56
+ proxy.PROXY_EMPTY_TOOL_LOOP_BREAK = old
57
+
58
+ def test_leaves_unforced_tool_choice_alone_but_still_directs(self):
59
+ # If tool_choice isn't 'required' (already auto), don't clobber it, but
60
+ # still surface the directive so the model breaks the empty streak.
61
+ body = {"tool_choice": "auto", "messages": [{"role": "user", "content": "go"}]}
62
+ self.m.consecutive_empty_tool_turns = proxy.PROXY_EMPTY_TOOL_LOOP_BREAK
63
+ proxy._maybe_break_empty_tool_loop(body, self.m)
64
+ self.assertEqual(body["tool_choice"], "auto")
65
+ self.assertEqual(len(body["messages"]), 2)
66
+
67
+
68
+ if __name__ == "__main__":
69
+ unittest.main()