@miller-tech/uap 1.118.1 → 1.119.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.
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """Unit tests for cycle-break tool narrowing preserving the exploration escape hatch.
3
+
4
+ Regression: the CYCLE BREAK narrowing removed every cycling tool by name (and the
5
+ whole read-only class if any cycling tool was read-only). Because Bash can be a
6
+ cycling tool, the agent lost the Bash tool entirely and could no longer run any
7
+ bash command to explore the filesystem. The narrowing must keep the open-ended
8
+ exploration escape hatch (Bash/WebFetch/Agent) available so the agent can make a
9
+ DIFFERENT move — the cycle *hint* handles "vary the command".
10
+ """
11
+
12
+ import importlib.util
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+
17
+ def _load_proxy_module():
18
+ proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
19
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
20
+ assert spec is not None and spec.loader is not None
21
+ module = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ proxy = _load_proxy_module()
27
+
28
+
29
+ def _tools(*names):
30
+ return [{"function": {"name": n}} for n in names]
31
+
32
+
33
+ def _names(tools):
34
+ return sorted(t["function"]["name"] for t in tools)
35
+
36
+
37
+ class TestCycleBreakExploration(unittest.TestCase):
38
+ def test_cycling_bash_keeps_bash(self):
39
+ """The core bug: cycling on Bash must NOT remove the Bash tool."""
40
+ tools = _tools("Bash", "Read", "Write")
41
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(tools, {"Bash"}, set())
42
+ self.assertIn("Bash", _names(narrowed))
43
+ self.assertFalse(expanded) # Bash is not read-only, so no class expansion
44
+
45
+ def test_read_only_cycle_expands_class_but_keeps_exploration(self):
46
+ """Cycling a read-only tool drops the whole read-only class, yet the
47
+ exploration escape hatch (Bash/WebFetch/Agent) survives."""
48
+ tools = _tools("Read", "Grep", "Glob", "Bash", "WebFetch", "Agent", "Write")
49
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(tools, {"Grep"}, set())
50
+ self.assertTrue(expanded)
51
+ kept = _names(narrowed)
52
+ # read-only class dropped
53
+ for n in ("Read", "Grep", "Glob"):
54
+ self.assertNotIn(n, kept)
55
+ # exploration escape hatch + writes preserved
56
+ for n in ("Bash", "WebFetch", "Agent", "Write"):
57
+ self.assertIn(n, kept)
58
+
59
+ def test_session_ban_is_honored_even_for_exploration_tools(self):
60
+ """An explicit session ban is a stronger, deliberate signal than cycling
61
+ and IS honored — even for an exploration tool. (Only the cycling path
62
+ preserves the escape hatch.)"""
63
+ tools = _tools("Bash", "Read", "Write")
64
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, set(), {"Bash"})
65
+ self.assertNotIn("Bash", _names(narrowed))
66
+
67
+ def test_cycling_bash_kept_even_when_another_tool_is_banned(self):
68
+ """A cycling Bash is preserved even while an unrelated tool is banned."""
69
+ tools = _tools("Bash", "Read", "Task")
70
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, {"Bash"}, {"Task"})
71
+ kept = _names(narrowed)
72
+ self.assertIn("Bash", kept) # cycling exploration hatch preserved
73
+ self.assertNotIn("Task", kept) # explicit ban honored
74
+
75
+ def test_case_insensitive_matching(self):
76
+ """Lowercase 'bash' in the cycling set must still match a 'Bash' tool
77
+ (and keep it), and 'read' must drop a 'Read' tool."""
78
+ tools = _tools("Bash", "Read")
79
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(
80
+ tools, {"bash", "read"}, set()
81
+ )
82
+ kept = _names(narrowed)
83
+ self.assertIn("Bash", kept) # exploration hatch kept despite cycling
84
+ self.assertNotIn("Read", kept) # read-only dropped
85
+ self.assertTrue(expanded)
86
+
87
+ def test_floor_keeps_last_action_tool_when_ban_would_strip_it(self):
88
+ """Floor invariant: if honoring a ban would leave NO exploration tool and
89
+ NO write tool, keep the original set — a loop-breaker must never strand
90
+ the agent with no way to make a different move."""
91
+ tools = _tools("Bash", "Read") # Read is read-only, not an action path
92
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, set(), {"Bash"})
93
+ self.assertIn("Bash", _names(narrowed)) # floor restored the escape hatch
94
+
95
+ def test_floor_not_triggered_when_a_write_tool_survives(self):
96
+ """The ban IS honored when another action path (a write tool) remains."""
97
+ tools = _tools("Bash", "Read", "Write")
98
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, set(), {"Bash"})
99
+ kept = _names(narrowed)
100
+ self.assertNotIn("Bash", kept) # honored: Write still gives an action path
101
+ self.assertIn("Write", kept)
102
+
103
+ def test_tool_class_taxonomy_is_pairwise_disjoint(self):
104
+ """Regression insurance: the three tool-class sets must never overlap, or
105
+ convergence accounting / cycle-break scope would silently change."""
106
+ ro = {c.lower() for c in proxy._READ_ONLY_TOOL_CLASS}
107
+ wr = {c.lower() for c in proxy._WRITE_TOOL_CLASS}
108
+ ex = {c.lower() for c in proxy._EXPLORATION_ESCAPE_TOOLS}
109
+ self.assertEqual(ro & wr, set())
110
+ self.assertEqual(ro & ex, set())
111
+ self.assertEqual(wr & ex, set())
112
+
113
+ def test_non_exploration_non_readonly_tool_dropped(self):
114
+ """A cycling tool that is neither read-only nor an exploration hatch is
115
+ dropped, and the read-only class is NOT expanded."""
116
+ tools = _tools("SomeTool", "Read", "Bash")
117
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(
118
+ tools, {"SomeTool"}, set()
119
+ )
120
+ kept = _names(narrowed)
121
+ self.assertNotIn("SomeTool", kept)
122
+ self.assertIn("Read", kept) # class not expanded, Read survives
123
+ self.assertIn("Bash", kept)
124
+ self.assertFalse(expanded)
125
+
126
+
127
+ class TestAutoBanExemption(unittest.TestCase):
128
+ """The session-ban accumulator is cycling-derived (ban after N detections).
129
+ Exploration escape-hatch tools must never be auto-banned, or the 'cannot
130
+ explore the filesystem' bug reappears on the Nth cycle."""
131
+
132
+ def test_bash_never_auto_banned_even_far_past_threshold(self):
133
+ self.assertFalse(proxy._should_auto_ban("bash", cycle_count=99, ban_at=3))
134
+ self.assertFalse(proxy._should_auto_ban("Bash", cycle_count=3, ban_at=3))
135
+
136
+ def test_other_exploration_tools_never_auto_banned(self):
137
+ for name in ("webfetch", "WebSearch", "agent", "task"):
138
+ self.assertFalse(proxy._should_auto_ban(name, cycle_count=10, ban_at=3))
139
+
140
+ def test_non_exploration_tool_banned_at_threshold(self):
141
+ self.assertTrue(proxy._should_auto_ban("edit", cycle_count=3, ban_at=3))
142
+ self.assertTrue(proxy._should_auto_ban("read", cycle_count=3, ban_at=3))
143
+
144
+ def test_non_exploration_tool_not_banned_below_threshold(self):
145
+ self.assertFalse(proxy._should_auto_ban("edit", cycle_count=2, ban_at=3))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """Unit tests for the DEFERRAL-BREAK guardrail (Fix A) and the no-tool-turn
3
+ recon-convergence counting (Fix B).
4
+
5
+ Regression: a model could end a turn with plain prose that DEFERS the work --
6
+ "I need more exploration cycles to complete the plan" -- with no tool call. That
7
+ stall was invisible to STUCK-BREAK (not a loop-admission phrase) and to
8
+ recon-convergence (a no-tool turn never advanced its streak), so it silently
9
+ halted a hands-free build. The deferral-break detects the no-tool capitulation
10
+ and forces the next turn to take a concrete action; Fix B makes prose-only turns
11
+ count toward convergence so prolonged stalls also escalate.
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
+
31
+ class TestDeferralPhraseRegex(unittest.TestCase):
32
+ def test_matches_the_reported_stall(self):
33
+ self.assertTrue(
34
+ proxy._DEFERRAL_PHRASE_RE.search(
35
+ "I need more exploration cycles to complete the plan. "
36
+ )
37
+ )
38
+
39
+ def test_matches_close_kin(self):
40
+ for s in (
41
+ "Let me continue exploring the codebase before I proceed.",
42
+ "I'll need a few more passes to complete the plan.",
43
+ "I need to gather more research before writing anything.",
44
+ "Once I have explored more, I can start.",
45
+ ):
46
+ self.assertTrue(proxy._DEFERRAL_PHRASE_RE.search(s), s)
47
+
48
+ def test_does_not_match_progress_narration(self):
49
+ for s in (
50
+ "Done. I created src/index.ts and wired the routes.",
51
+ "Here is the summary of what I built.",
52
+ "Running the tests now.",
53
+ "The build passes and all items are complete.",
54
+ ):
55
+ self.assertIsNone(proxy._DEFERRAL_PHRASE_RE.search(s), s)
56
+
57
+ def test_does_not_match_generic_time_or_steps_question(self):
58
+ # A legitimate clarifying question must not trip the guard (was a false
59
+ # positive when bare "time"/"steps" were object tokens).
60
+ for s in (
61
+ "I need more time to review your requirements — could you clarify X?",
62
+ "There are a few more steps, let me know which environment to target.",
63
+ ):
64
+ self.assertIsNone(proxy._DEFERRAL_PHRASE_RE.search(s), s)
65
+
66
+
67
+ class TestDeferralStreak(unittest.TestCase):
68
+ def test_no_tool_deferral_increments_and_fires(self):
69
+ mon = proxy.SessionMonitor()
70
+ mon.note_deferral_signal(
71
+ "I need more exploration cycles to complete the plan.", had_tool_call=False
72
+ )
73
+ self.assertEqual(mon.deferral_streak, 1)
74
+ should, reason = mon.should_force_deferral_break()
75
+ self.assertTrue(should)
76
+ self.assertIn("deferral", reason)
77
+
78
+ def test_tool_call_turn_resets_streak(self):
79
+ mon = proxy.SessionMonitor()
80
+ mon.note_deferral_signal("let me continue exploring", had_tool_call=False)
81
+ self.assertEqual(mon.deferral_streak, 1)
82
+ # Model actually acted this turn -> not deferring anymore.
83
+ mon.note_deferral_signal("let me continue exploring", had_tool_call=True)
84
+ self.assertEqual(mon.deferral_streak, 0)
85
+ self.assertFalse(mon.should_force_deferral_break()[0])
86
+
87
+ def test_non_matching_text_resets_streak(self):
88
+ mon = proxy.SessionMonitor()
89
+ mon.note_deferral_signal("I need more cycles", had_tool_call=False)
90
+ self.assertEqual(mon.deferral_streak, 1)
91
+ mon.note_deferral_signal("All done, files written.", had_tool_call=False)
92
+ self.assertEqual(mon.deferral_streak, 0)
93
+
94
+
95
+ class TestDeferralInjection(unittest.TestCase):
96
+ def test_forces_tool_choice_and_directive(self):
97
+ mon = proxy.SessionMonitor()
98
+ mon.note_deferral_signal("I need more exploration cycles", had_tool_call=False)
99
+ body = {
100
+ "tools": [{"function": {"name": "Write"}}],
101
+ "tool_choice": "auto",
102
+ "messages": [{"role": "system", "content": "sys"}],
103
+ }
104
+ proxy._maybe_inject_deferral_break(body, mon)
105
+ self.assertEqual(body["tool_choice"], "required")
106
+ self.assertIn("CONTINUE AUTONOMOUSLY", body["messages"][0]["content"])
107
+ self.assertEqual(mon.deferral_break_fires, 1)
108
+
109
+ def test_does_not_fire_below_threshold(self):
110
+ mon = proxy.SessionMonitor() # streak 0
111
+ body = {"tools": [{"function": {"name": "Write"}}], "tool_choice": "auto", "messages": []}
112
+ proxy._maybe_inject_deferral_break(body, mon)
113
+ self.assertEqual(body["tool_choice"], "auto")
114
+ self.assertEqual(mon.deferral_break_fires, 0)
115
+
116
+ def test_yields_to_stuck_break(self):
117
+ mon = proxy.SessionMonitor()
118
+ mon.deferral_streak = 5
119
+ mon.self_stuck_streak = 99 # stuck-break will fire this turn
120
+ body = {"tools": [{"function": {"name": "Write"}}], "tool_choice": "auto", "messages": []}
121
+ proxy._maybe_inject_deferral_break(body, mon)
122
+ # deferral defers to the more-urgent stuck-break (prose exit).
123
+ self.assertEqual(body["tool_choice"], "auto")
124
+ self.assertEqual(mon.deferral_break_fires, 0)
125
+
126
+ def test_disabled_when_off(self):
127
+ mon = proxy.SessionMonitor()
128
+ # Simulate the phrase but with the guard disabled.
129
+ original = proxy.PROXY_DEFERRAL_BREAK
130
+ try:
131
+ proxy.PROXY_DEFERRAL_BREAK = False
132
+ mon.note_deferral_signal("I need more exploration cycles", had_tool_call=False)
133
+ self.assertEqual(mon.deferral_streak, 0)
134
+ self.assertFalse(mon.should_force_deferral_break()[0])
135
+ finally:
136
+ proxy.PROXY_DEFERRAL_BREAK = original
137
+
138
+
139
+ class TestDeferralYieldsAndToolAbsence(unittest.TestCase):
140
+ def test_yields_to_recon_convergence(self):
141
+ mon = proxy.SessionMonitor()
142
+ mon.deferral_streak = 5
143
+ # Push the no-write streak into recon-convergence territory.
144
+ mon.consecutive_no_write_turns = proxy.PROXY_RECON_CONVERGENCE_THRESHOLD
145
+ body = {
146
+ "tools": [{"function": {"name": "Write"}}],
147
+ "tool_choice": "auto",
148
+ "messages": [],
149
+ }
150
+ proxy._maybe_inject_deferral_break(body, mon)
151
+ # recon owns the turn -> deferral must not fire or touch tool_choice.
152
+ self.assertEqual(body["tool_choice"], "auto")
153
+ self.assertEqual(mon.deferral_break_fires, 0)
154
+
155
+ def test_no_tools_injects_directive_but_leaves_tool_choice(self):
156
+ mon = proxy.SessionMonitor()
157
+ mon.deferral_streak = 1 # recon NOT pending (streak stays 0)
158
+ body = {"tools": [], "tool_choice": "auto", "messages": []}
159
+ proxy._maybe_inject_deferral_break(body, mon)
160
+ # No tools to force, so tool_choice is left as-is, but the directive and
161
+ # the telemetry counter still apply (the nudge is still worth sending).
162
+ self.assertEqual(body["tool_choice"], "auto")
163
+ self.assertEqual(mon.deferral_break_fires, 1)
164
+ self.assertIn("CONTINUE AUTONOMOUSLY", body["messages"][0]["content"])
165
+
166
+
167
+ class TestNoToolTurnConvergence(unittest.TestCase):
168
+ def test_no_tool_turn_advances_convergence_streak(self):
169
+ mon = proxy.SessionMonitor()
170
+ before = mon.consecutive_no_write_turns
171
+ mon.note_no_tool_turn()
172
+ mon.note_no_tool_turn()
173
+ self.assertEqual(mon.consecutive_no_write_turns, before + 2)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ unittest.main()